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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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;