Skip to main content

lavende_core/sources/
gaana.rs

1pub mod crypto {
2    use cbc::cipher::{BlockDecryptMut, KeyIvInit};
3    use tracing::warn;
4    type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
5    const CRYPTO_KEY: &[u8; 16] = b"gy1t#b@jl(b$wtme";
6    const CRYPTO_IV: &[u8; 16] = b"xC4dmVJAq14BfntX";
7    const HLS_BASE_URL: &str = "https://vodhlsgaana-ebw.akamaized.net/";
8    pub fn decrypt_stream_path(encrypted_data: &str) -> Option<String> {
9        if encrypted_data.is_empty() {
10            return None;
11        }
12        let offset = encrypted_data.chars().next()?.to_digit(10)? as usize;
13        let skip = offset + 16;
14        if skip >= encrypted_data.len() {
15            warn!(
16                "Gaana: encrypted data too short (len={}, skip={})",
17                encrypted_data.len(),
18                skip
19            );
20            return None;
21        }
22        let ciphertext_b64 = &encrypted_data[skip..];
23        let padded = format!(
24            "{}{}",
25            ciphertext_b64,
26            &"==="[..(4 - ciphertext_b64.len() % 4) % 4]
27        );
28        let ciphertext =
29            match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &padded) {
30                Ok(data) => data,
31                Err(e) => {
32                    warn!("Gaana: base64 decode failed: {}", e);
33                    return None;
34                }
35            };
36        if ciphertext.is_empty() || ciphertext.len() % 16 != 0 {
37            warn!("Gaana: invalid ciphertext length: {}", ciphertext.len());
38            return None;
39        }
40        let mut buf = ciphertext;
41        let cipher = Aes128CbcDec::new_from_slices(CRYPTO_KEY, CRYPTO_IV).ok()?;
42        let decrypted =
43            match cipher.decrypt_padded_mut::<cbc::cipher::block_padding::NoPadding>(&mut buf) {
44                Ok(d) => d,
45                Err(e) => {
46                    warn!("Gaana: AES decryption failed: {}", e);
47                    return None;
48                }
49            };
50        let raw_text: String = decrypted
51            .iter()
52            .filter(|&&b| (32..=126).contains(&b))
53            .map(|&b| b as char)
54            .collect();
55        if let Some(idx) = raw_text.find("hls/") {
56            let path = &raw_text[idx..];
57            Some(format!("{HLS_BASE_URL}{path}"))
58        } else {
59            warn!("Gaana: No /hls/ path found in decrypted text");
60            None
61        }
62    }
63}
64pub mod manager {
65    use super::track::GaanaTrack;
66    use crate::{
67        protocol::tracks::{LoadError, LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
68        sources::{SourcePlugin, playable_track::BoxedTrack},
69    };
70    use async_trait::async_trait;
71    use regex::Regex;
72    use serde_json::Value;
73    use std::sync::{Arc, OnceLock};
74    use tracing::warn;
75    const API_URL: &str = "https://gaana.com/apiv2";
76    const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36";
77    fn url_regex() -> &'static Regex {
78        static REGEX: OnceLock<Regex> = OnceLock::new();
79        REGEX.get_or_init(|| {
80        Regex::new(
81        r"(?:https?://)?(?:www\.)?gaana\.com/(?P<type>song|album|playlist|artist)/(?P<seokey>[\w-]+)",
82      )
83      .unwrap()
84    })
85    }
86    pub struct GaanaSource {
87        client: Arc<reqwest::Client>,
88        stream_quality: String,
89        proxy: Option<crate::config::HttpProxyConfig>,
90        search_limit: usize,
91        playlist_load_limit: usize,
92        album_load_limit: usize,
93        artist_load_limit: usize,
94    }
95    impl GaanaSource {
96        pub fn new(
97            config: Option<crate::config::GaanaConfig>,
98            client: Arc<reqwest::Client>,
99        ) -> Result<Self, String> {
100            let (
101                stream_quality,
102                search_limit,
103                playlist_load_limit,
104                album_load_limit,
105                artist_load_limit,
106                proxy,
107            ) = if let Some(c) = config {
108                (
109                    c.stream_quality.unwrap_or_else(|| "high".to_owned()),
110                    c.search_limit,
111                    c.playlist_load_limit,
112                    c.album_load_limit,
113                    c.artist_load_limit,
114                    c.proxy,
115                )
116            } else {
117                ("high".to_owned(), 10, 50, 50, 20, None)
118            };
119            Ok(Self {
120                client,
121                stream_quality,
122                proxy,
123                search_limit,
124                playlist_load_limit,
125                album_load_limit,
126                artist_load_limit,
127            })
128        }
129        async fn get_json(&self, params: &[(&str, &str)], referer_path: &str) -> Option<Value> {
130            let url = format!(
131                "{}?{}",
132                API_URL,
133                params
134                    .iter()
135                    .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
136                    .collect::<Vec<_>>()
137                    .join("&")
138            );
139            let resp = match self
140                .base_request(self.client.post(&url))
141                .header("Referer", format!("https://gaana.com/{}", referer_path))
142                .header("Content-Length", "0")
143                .send()
144                .await
145            {
146                Ok(r) => r,
147                Err(e) => {
148                    warn!("Gaana API request failed: {}", e);
149                    return None;
150                }
151            };
152            if !resp.status().is_success() {
153                return None;
154            }
155            let text = resp.text().await.ok()?;
156            serde_json::from_str::<Value>(&text).ok()
157        }
158        fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
159            builder
160                .header("User-Agent", USER_AGENT)
161                .header("Accept", "application/json, text/plain, */*")
162                .header("Accept-Language", "en-US,en;q=0.9")
163                .header("Accept-Encoding", "gzip, deflate, br")
164                .header("Origin", "https://gaana.com")
165                .header("Connection", "keep-alive")
166                .header("Sec-Fetch-Dest", "empty")
167                .header("Sec-Fetch-Mode", "cors")
168                .header("Sec-Fetch-Site", "same-origin")
169                .header(
170                    "sec-ch-ua",
171                    "\"Chromium\";v=\"136\", \"Google Chrome\";v=\"136\", \"Not.A/Brand\";v=\"99\"",
172                )
173                .header("sec-ch-ua-mobile", "?0")
174                .header("sec-ch-ua-platform", "\"Windows\"")
175        }
176        async fn load_song(&self, seokey: &str) -> LoadResult {
177            let params = [("type", "songDetail"), ("seokey", seokey)];
178            let data = match self.get_json(&params, &format!("song/{seokey}")).await {
179                Some(d) => d,
180                None => return LoadResult::Empty {},
181            };
182            let tracks = match data.get("tracks").and_then(|v| v.as_array()) {
183                Some(arr) if !arr.is_empty() => arr,
184                _ => return LoadResult::Empty {},
185            };
186            match self.parse_track(&tracks[0]) {
187                Some(track) => LoadResult::Track(track),
188                None => LoadResult::Empty {},
189            }
190        }
191        async fn load_album(&self, seokey: &str) -> LoadResult {
192            let params = [("type", "albumDetail"), ("seokey", seokey)];
193            let data = match self.get_json(&params, &format!("album/{seokey}")).await {
194                Some(d) => d,
195                None => return LoadResult::Empty {},
196            };
197            let tracks_arr = match data.get("tracks").and_then(|v| v.as_array()) {
198                Some(arr) if !arr.is_empty() => arr,
199                _ => return LoadResult::Empty {},
200            };
201            let album = data.get("album").unwrap_or(&Value::Null);
202            let name = album
203                .get("title")
204                .and_then(|v| v.as_str())
205                .unwrap_or("Unknown Album");
206            let tracks: Vec<Track> = tracks_arr
207                .iter()
208                .take(self.album_load_limit)
209                .filter_map(|t| self.parse_track(t))
210                .collect();
211            if tracks.is_empty() {
212                return LoadResult::Empty {};
213            }
214            LoadResult::Playlist(PlaylistData {
215                info: PlaylistInfo {
216                    name: name.to_owned(),
217                    selected_track: -1,
218                },
219                plugin_info: serde_json::json!({
220                    "type": "album",
221                    "url": format!("https://gaana.com/album/{seokey}"),
222                    "artworkUrl": album.get("atw").or_else(|| album.get("artwork_large")).and_then(|v| v.as_str()),
223                    "author": album.get("artist").and_then(|a| a.as_array()).and_then(|arr| arr.first()).and_then(|a| a.get("name")).and_then(|v| v.as_str()),
224                    "totalTracks": tracks.len()
225                }),
226                tracks,
227            })
228        }
229        async fn load_playlist(&self, seokey: &str) -> LoadResult {
230            let params = [("type", "playlistDetail"), ("seokey", seokey)];
231            let data = match self.get_json(&params, &format!("playlist/{seokey}")).await {
232                Some(d) => d,
233                None => return LoadResult::Empty {},
234            };
235            let tracks_arr = match data.get("tracks").and_then(|v| v.as_array()) {
236                Some(arr) if !arr.is_empty() => arr,
237                _ => return LoadResult::Empty {},
238            };
239            let playlist = data.get("playlist").unwrap_or(&Value::Null);
240            let name = playlist
241                .get("title")
242                .and_then(|v| v.as_str())
243                .unwrap_or("Unknown Playlist");
244            let tracks: Vec<Track> = tracks_arr
245                .iter()
246                .take(self.playlist_load_limit)
247                .filter_map(|t| self.parse_track(t))
248                .collect();
249            if tracks.is_empty() {
250                return LoadResult::Empty {};
251            }
252            LoadResult::Playlist(PlaylistData {
253                info: PlaylistInfo {
254                    name: name.to_owned(),
255                    selected_track: -1,
256                },
257                plugin_info: serde_json::json!({
258                    "type": "playlist",
259                    "url": format!("https://gaana.com/playlist/{seokey}"),
260                    "artworkUrl": playlist.get("atw").or_else(|| playlist.get("artwork_large")).and_then(|v| v.as_str()),
261                    "author": playlist.get("created_by").and_then(|v| v.as_str()),
262                    "totalTracks": tracks.len()
263                }),
264                tracks,
265            })
266        }
267        async fn load_artist(&self, seokey: &str) -> LoadResult {
268            let detail_params = [("type", "artistDetail"), ("seokey", seokey)];
269            let detail = match self
270                .get_json(&detail_params, &format!("artist/{seokey}"))
271                .await
272            {
273                Some(d) => d,
274                None => return LoadResult::Empty {},
275            };
276            let artist_arr = match detail.get("artist").and_then(|v| v.as_array()) {
277                Some(arr) if !arr.is_empty() => arr,
278                _ => return LoadResult::Empty {},
279            };
280            let artist_data = &artist_arr[0];
281            let artist_id = match artist_data.get("artist_id").and_then(|v| {
282                v.as_str()
283                    .map(|s| s.to_owned())
284                    .or_else(|| v.as_i64().map(|i| i.to_string()))
285            }) {
286                Some(id) => id,
287                None => return LoadResult::Empty {},
288            };
289            let artist_name = artist_data
290                .get("name")
291                .and_then(|v| v.as_str())
292                .unwrap_or("Unknown Artist");
293            let tracks_params = [
294                ("language", ""),
295                ("order", "0"),
296                ("page", "0"),
297                ("sortBy", "popularity"),
298                ("type", "artistTrackList"),
299                ("id", &artist_id),
300            ];
301            let tracks_data = match self
302                .get_json(&tracks_params, &format!("artist/{seokey}"))
303                .await
304            {
305                Some(d) => d,
306                None => return LoadResult::Empty {},
307            };
308            let entities_arr = match tracks_data.get("entities").and_then(|v| v.as_array()) {
309                Some(arr) if !arr.is_empty() => arr,
310                _ => return LoadResult::Empty {},
311            };
312            let tracks: Vec<Track> = entities_arr
313                .iter()
314                .take(self.artist_load_limit)
315                .filter_map(|t| self.parse_entity_track(t))
316                .collect();
317            if tracks.is_empty() {
318                return LoadResult::Empty {};
319            }
320            LoadResult::Playlist(PlaylistData {
321                info: PlaylistInfo {
322                    name: format!("{artist_name}'s Top Tracks"),
323                    selected_track: -1,
324                },
325                plugin_info: serde_json::json!({
326                    "type": "artist",
327                    "url": format!("https://gaana.com/artist/{seokey}"),
328                    "artworkUrl": artist_data.get("atw").and_then(|v| v.as_str()),
329                    "author": artist_name,
330                    "totalTracks": tracks.len()
331                }),
332                tracks,
333            })
334        }
335        async fn search(&self, query: &str) -> LoadResult {
336            let params = [
337                ("country", "IN"),
338                ("page", "0"),
339                ("secType", "track"),
340                ("type", "search"),
341                ("keyword", query),
342            ];
343            let data = match self
344                .get_json(&params, &format!("search/{}", urlencoding::encode(query)))
345                .await
346            {
347                Some(d) => d,
348                None => {
349                    return LoadResult::Error(LoadError {
350                        message: Some("Gaana search failed".to_owned()),
351                        severity: crate::common::Severity::Common,
352                        cause: String::new(),
353                        cause_stack_trace: None,
354                    });
355                }
356            };
357            let gr = match data.get("gr").and_then(|v| v.as_array()) {
358                Some(arr) => arr,
359                None => return LoadResult::Empty {},
360            };
361            let track_group = gr
362                .iter()
363                .find(|g| g.get("ty").and_then(|v| v.as_str()) == Some("Track"));
364            let items = match track_group
365                .and_then(|g| g.get("gd"))
366                .and_then(|v| v.as_array())
367            {
368                Some(arr) if !arr.is_empty() => arr,
369                _ => return LoadResult::Empty {},
370            };
371            let mut results = Vec::new();
372            for item in items.iter().take(self.search_limit) {
373                let seokey = item
374                    .get("seo")
375                    .and_then(|v| v.as_str())
376                    .or_else(|| item.get("id").and_then(|v| v.as_str()));
377                if let Some(key) = seokey
378                    && let LoadResult::Track(track) = self.load_song(key).await
379                {
380                    results.push(track);
381                }
382            }
383            if results.is_empty() {
384                LoadResult::Empty {}
385            } else {
386                LoadResult::Search(results)
387            }
388        }
389        fn extract_isrc(&self, json: &Value) -> Option<String> {
390            if let Some(isrc) = json.get("isrc").and_then(|v| v.as_str()) {
391                return Some(isrc.to_owned());
392            }
393            if let Some(info) = json.get("entity_info").and_then(|v| v.as_array()) {
394                return info.iter().find_map(|e| {
395                    if e.get("key").and_then(|k| k.as_str()) == Some("isrc") {
396                        e.get("value")
397                            .and_then(|v| v.as_str())
398                            .map(|s| s.to_owned())
399                    } else {
400                        None
401                    }
402                });
403            }
404            None
405        }
406        fn parse_track(&self, json: &Value) -> Option<Track> {
407            let id = json
408                .get("track_id")
409                .and_then(|v| {
410                    v.as_str().map(|s| s.to_owned()).or_else(|| {
411                        v.as_i64()
412                            .map(|i| i.to_string())
413                            .or_else(|| v.as_u64().map(|i| i.to_string()))
414                    })
415                })
416                .or_else(|| {
417                    json.get("entity_id").and_then(|v| {
418                        v.as_str()
419                            .map(|s| s.to_owned())
420                            .or_else(|| v.as_i64().map(|i| i.to_string()))
421                    })
422                })?;
423            let title = json
424                .get("track_title")
425                .and_then(|v| v.as_str())
426                .or_else(|| json.get("name").and_then(|v| v.as_str()))?;
427            let duration = json
428                .get("duration")
429                .and_then(|v| {
430                    v.as_u64()
431                        .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
432                })
433                .unwrap_or(0)
434                * 1000;
435            let author = if let Some(artist_arr) = json.get("artist").and_then(|v| v.as_array()) {
436                let names: Vec<&str> = artist_arr
437                    .iter()
438                    .filter_map(|a| a.get("name").and_then(|v| v.as_str()))
439                    .collect();
440                if names.is_empty() {
441                    "Unknown Artist".to_owned()
442                } else {
443                    names.join(", ")
444                }
445            } else {
446                "Unknown Artist".to_owned()
447            };
448            let seokey = json.get("seokey").and_then(|v| v.as_str());
449            let uri = seokey.map(|s| format!("https://gaana.com/song/{s}"));
450            let artwork_url = json
451                .get("artwork_large")
452                .and_then(|v| v.as_str())
453                .filter(|s| !s.is_empty())
454                .or_else(|| {
455                    json.get("atw")
456                        .and_then(|v| v.as_str())
457                        .filter(|s| !s.is_empty())
458                })
459                .map(|s| s.to_owned());
460            let isrc = self.extract_isrc(json);
461            let track_info = TrackInfo {
462                identifier: id,
463                is_seekable: true,
464                author,
465                length: duration,
466                is_stream: false,
467                position: 0,
468                title: title.to_owned(),
469                uri,
470                artwork_url,
471                isrc,
472                source_name: "gaana".to_owned(),
473            };
474            Some(Track::new(track_info))
475        }
476        fn parse_entity_track(&self, json: &Value) -> Option<Track> {
477            let id = json.get("entity_id").and_then(|v| {
478                v.as_str()
479                    .map(|s| s.to_owned())
480                    .or_else(|| v.as_i64().map(|i| i.to_string()))
481            })?;
482            let title = json.get("name").and_then(|v| v.as_str())?;
483            let entity_info = json.get("entity_info").and_then(|v| v.as_array());
484            let get_entity_value = |key: &str| -> Option<&Value> {
485                entity_info?.iter().find_map(|e| {
486                    if e.get("key").and_then(|k| k.as_str()) == Some(key) {
487                        e.get("value")
488                    } else {
489                        None
490                    }
491                })
492            };
493            let duration = get_entity_value("duration")
494                .and_then(|v| {
495                    v.as_u64()
496                        .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
497                })
498                .unwrap_or(0)
499                * 1000;
500            let author = get_entity_value("artist")
501                .and_then(|v| v.as_array())
502                .map(|arr| {
503                    arr.iter()
504                        .filter_map(|a| a.get("name").and_then(|n| n.as_str()))
505                        .collect::<Vec<_>>()
506                        .join(", ")
507                })
508                .unwrap_or_else(|| "Unknown Artist".to_owned());
509            let seokey = json.get("seokey").and_then(|v| v.as_str());
510            let uri = seokey.map(|s| format!("https://gaana.com/song/{s}"));
511            let artwork_url = json
512                .get("atw")
513                .and_then(|v| v.as_str())
514                .map(|s| s.to_owned());
515            let isrc = self.extract_isrc(json);
516            let track_info = TrackInfo {
517                identifier: id,
518                is_seekable: true,
519                author,
520                length: duration,
521                is_stream: false,
522                position: 0,
523                title: title.to_owned(),
524                uri,
525                artwork_url,
526                isrc,
527                source_name: "gaana".to_owned(),
528            };
529            Some(Track::new(track_info))
530        }
531    }
532    #[async_trait]
533    impl SourcePlugin for GaanaSource {
534        fn name(&self) -> &str {
535            "gaana"
536        }
537        fn can_handle(&self, identifier: &str) -> bool {
538            self.search_prefixes()
539                .iter()
540                .any(|p| identifier.starts_with(p))
541                || url_regex().is_match(identifier)
542        }
543        fn search_prefixes(&self) -> Vec<&str> {
544            vec!["gnsearch:", "gaanasearch:"]
545        }
546        async fn load(
547            &self,
548            identifier: &str,
549            _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
550        ) -> LoadResult {
551            for prefix in self.search_prefixes() {
552                if let Some(query) = identifier.strip_prefix(prefix) {
553                    return self.search(query.trim()).await;
554                }
555            }
556            if let Some(caps) = url_regex().captures(identifier) {
557                let type_ = caps.name("type").map(|m| m.as_str()).unwrap_or("");
558                let seokey = caps.name("seokey").map(|m| m.as_str()).unwrap_or("");
559                if seokey.is_empty() || type_.is_empty() {
560                    return LoadResult::Empty {};
561                }
562                return match type_ {
563                    "song" => self.load_song(seokey).await,
564                    "album" => self.load_album(seokey).await,
565                    "playlist" => self.load_playlist(seokey).await,
566                    "artist" => self.load_artist(seokey).await,
567                    _ => LoadResult::Empty {},
568                };
569            }
570            LoadResult::Empty {}
571        }
572        async fn get_track(
573            &self,
574            identifier: &str,
575            routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
576        ) -> Option<BoxedTrack> {
577            let track_id = if let Some(caps) = url_regex().captures(identifier) {
578                if caps.name("type").map(|m| m.as_str()) != Some("song") {
579                    return None;
580                }
581                let seokey = caps.name("seokey").map(|m| m.as_str())?;
582                let params = [("type", "songDetail"), ("seokey", seokey)];
583                let data = self.get_json(&params, &format!("song/{seokey}")).await?;
584                data.get("tracks")?
585                    .as_array()?
586                    .first()?
587                    .get("track_id")?
588                    .as_str()?
589                    .to_owned()
590            } else {
591                identifier.to_owned()
592            };
593            let stream_url = super::track::fetch_stream_url_internal(
594                &self.client,
595                &track_id,
596                &self.stream_quality,
597            )
598            .await;
599            if stream_url.is_none() {
600                warn!("Gaana: no stream URL for track {track_id}, falling back to mirrors");
601                return None;
602            }
603            Some(Arc::new(GaanaTrack {
604                client: self.client.clone(),
605                track_id,
606                stream_quality: self.stream_quality.clone(),
607                local_addr: routeplanner.and_then(|rp| rp.get_address()),
608                proxy: self.proxy.clone(),
609            }))
610        }
611        fn get_proxy_config(&self) -> Option<crate::config::HttpProxyConfig> {
612            self.proxy.clone()
613        }
614    }
615}
616pub mod reader {
617    use crate::{
618        audio::source::{HttpSource, create_client},
619        common::types::AnyResult,
620    };
621    use std::io::{Read, Seek, SeekFrom};
622    use symphonia::core::io::MediaSource;
623    pub struct GaanaReader {
624        inner: HttpSource,
625    }
626    const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36";
627    impl GaanaReader {
628        pub async fn new(
629            url: &str,
630            local_addr: Option<std::net::IpAddr>,
631            proxy: Option<crate::config::HttpProxyConfig>,
632        ) -> AnyResult<Self> {
633            let client = create_client(USER_AGENT.to_owned(), local_addr, proxy, None)?;
634            let inner = HttpSource::new(client, url).await?;
635            Ok(Self { inner })
636        }
637    }
638    impl Read for GaanaReader {
639        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
640            self.inner.read(buf)
641        }
642    }
643    impl Seek for GaanaReader {
644        fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
645            self.inner.seek(pos)
646        }
647    }
648    impl MediaSource for GaanaReader {
649        fn is_seekable(&self) -> bool {
650            self.inner.is_seekable()
651        }
652        fn byte_len(&self) -> Option<u64> {
653            self.inner.byte_len()
654        }
655    }
656}
657pub mod track {
658    use crate::{
659        common::types::AudioFormat,
660        config::HttpProxyConfig,
661        sources::{
662            gaana::crypto::decrypt_stream_path,
663            playable_track::{PlayableTrack, ResolvedTrack},
664        },
665    };
666    use async_trait::async_trait;
667    use std::{net::IpAddr, sync::Arc};
668    pub struct GaanaTrack {
669        pub client: Arc<reqwest::Client>,
670        pub track_id: String,
671        pub stream_quality: String,
672        pub local_addr: Option<IpAddr>,
673        pub proxy: Option<HttpProxyConfig>,
674    }
675    #[async_trait]
676    impl PlayableTrack for GaanaTrack {
677        async fn resolve(&self) -> Result<ResolvedTrack, String> {
678            let url = fetch_stream_url_internal(&self.client, &self.track_id, &self.stream_quality)
679                .await
680                .ok_or_else(|| {
681                    format!(
682                        "GaanaTrack: Failed to fetch stream URL for {}",
683                        self.track_id
684                    )
685                })?;
686            let local_addr = self.local_addr;
687            let proxy = self.proxy.clone();
688            let is_hls = url.contains(".m3u8") || url.contains("/api/manifest/hls_");
689            if is_hls {
690                crate::sources::youtube::hls::HlsReader::new(&url, local_addr, None, None, proxy)
691                    .await
692                    .map(|r| {
693                        ResolvedTrack::new(
694                            Box::new(r) as Box<dyn symphonia::core::io::MediaSource>,
695                            Some(AudioFormat::Aac),
696                        )
697                    })
698                    .map_err(|e| format!("Failed to init HLS reader: {e}"))
699            } else {
700                let hint = std::path::Path::new(&url)
701                    .extension()
702                    .and_then(|s| s.to_str())
703                    .map(AudioFormat::from_ext);
704                super::reader::GaanaReader::new(&url, local_addr, proxy)
705                    .await
706                    .map(|r| {
707                        ResolvedTrack::new(
708                            Box::new(r) as Box<dyn symphonia::core::io::MediaSource>,
709                            hint,
710                        )
711                    })
712                    .map_err(|e| format!("Failed to init reader: {e}"))
713            }
714        }
715    }
716    pub(super) async fn fetch_stream_url_internal(
717        client: &Arc<reqwest::Client>,
718        track_id: &str,
719        quality: &str,
720    ) -> Option<String> {
721        let body = format!(
722            "quality={}&track_id={}&stream_format=mp4",
723            urlencoding::encode(quality),
724            urlencoding::encode(track_id)
725        );
726        let resp = client
727        .post("https://gaana.com/api/stream-url")
728        .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36")
729        .header("Referer", "https://gaana.com/")
730        .header("Origin", "https://gaana.com")
731        .header("Accept", "application/json, text/plain, */*")
732        .header("Content-Type", "application/x-www-form-urlencoded")
733        .body(body)
734        .send()
735        .await
736        .ok()?;
737        if !resp.status().is_success() {
738            return None;
739        }
740        let data: serde_json::Value = resp.json().await.ok()?;
741        let encrypted_path = data.get("data")?.get("stream_path")?.as_str()?;
742        decrypt_stream_path(encrypted_path)
743    }
744}
745pub use manager::GaanaSource;
746pub use track::GaanaTrack;