1pub mod helpers {
2 use serde_json::Value;
3 use tracing::warn;
4 const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36";
5 pub async fn get_json(
6 client: &reqwest::Client,
7 api_url: &str,
8 params: &[(&str, &str)],
9 ) -> Option<Value> {
10 let resp = match client
11 .get(api_url)
12 .header("User-Agent", USER_AGENT)
13 .header("Accept", "application/json")
14 .header("Accept-Language", "en-US,en;q=0.9")
15 .header("Referer", "https://www.jiosaavn.com/")
16 .header("Origin", "https://www.jiosaavn.com")
17 .query(params)
18 .send()
19 .await
20 {
21 Ok(r) => r,
22 Err(e) => {
23 warn!("JioSaavn request failed: {e}");
24 return None;
25 }
26 };
27 if !resp.status().is_success() {
28 warn!("JioSaavn API error status: {}", resp.status());
29 return None;
30 }
31 let text = match resp.text().await {
32 Ok(text) => text,
33 Err(e) => {
34 warn!("Failed to read JioSaavn response body: {e}");
35 return None;
36 }
37 };
38 serde_json::from_str(&text).ok()
39 }
40 pub fn clean_string(s: &str) -> String {
41 s.replace(""", "\"").replace("&", "&")
42 }
43}
44pub mod metadata {
45 use super::{
46 JioSaavnSource,
47 helpers::{clean_string, get_json},
48 parser::parse_track,
49 };
50 use crate::protocol::tracks::{LoadError, LoadResult, PlaylistData, PlaylistInfo};
51 use serde_json::Value;
52 impl JioSaavnSource {
53 pub async fn fetch_metadata(&self, id: &str) -> Option<Value> {
54 let params = vec![
55 ("__call", "webapi.get"),
56 ("api_version", "4"),
57 ("_format", "json"),
58 ("_marker", "0"),
59 ("ctx", "web6dot0"),
60 ("token", id),
61 ("type", "song"),
62 ];
63 get_json(&self.client, &self.api_url, ¶ms)
64 .await
65 .and_then(|json| {
66 json.get("songs")
67 .and_then(|s| s.get(0))
68 .cloned()
69 .or_else(|| (json.get("id").is_some()).then_some(json))
70 })
71 }
72 pub async fn resolve_list(&self, type_: &str, id: &str) -> LoadResult {
73 let t = if type_ == "featured" || type_ == "s/playlist" {
74 "playlist"
75 } else {
76 type_
77 };
78 let n = if type_ == "artist" {
79 self.artist_load_limit
80 } else if type_ == "album" {
81 self.album_load_limit
82 } else {
83 self.playlist_load_limit
84 };
85 let n_str = n.to_string();
86 let mut params = vec![
87 ("__call", "webapi.get"),
88 ("api_version", "4"),
89 ("_format", "json"),
90 ("_marker", "0"),
91 ("ctx", "web6dot0"),
92 ("token", id),
93 ("type", t),
94 ];
95 if type_ == "artist" {
96 params.push(("n_song", &n_str));
97 } else {
98 params.push(("n", &n_str));
99 }
100 if let Some(data) = get_json(&self.client, &self.api_url, ¶ms).await {
101 let list = data
102 .get("list")
103 .or_else(|| data.get("topSongs"))
104 .and_then(|v| v.as_array());
105 if let Some(arr) = list
106 && !arr.is_empty()
107 {
108 let tracks: Vec<_> = arr.iter().filter_map(parse_track).collect();
109 let mut name = clean_string(
110 data.get("title")
111 .or_else(|| data.get("name"))
112 .and_then(|v| v.as_str())
113 .unwrap_or(""),
114 );
115 if type_ == "artist" {
116 name = format!("{name}'s Top Tracks");
117 }
118 return LoadResult::Playlist(PlaylistData {
119 info: PlaylistInfo {
120 name,
121 selected_track: -1,
122 },
123 plugin_info: serde_json::json!({
124 "url": data.get("perma_url").and_then(|v| v.as_str()),
125 "type": type_,
126 "artworkUrl": data.get("image").and_then(|v| v.as_str()).map(|s| s.replace("150x150", "500x500").replace("50x50", "500x500")),
127 "author": data.get("subtitle").or_else(|| data.get("header_desc")).and_then(|v| v.as_str()).map(|s| s.split(',').take(3).collect::<Vec<_>>().join(", ")),
128 "totalTracks": data.get("list_count").and_then(|v| v.as_str()).and_then(|s| s.parse::<u64>().ok()).unwrap_or(tracks.len() as u64)
129 }),
130 tracks,
131 });
132 }
133 LoadResult::Empty {}
134 } else {
135 LoadResult::Error(LoadError {
136 message: Some("JioSaavn list fetch failed".to_owned()),
137 severity: crate::common::Severity::Common,
138 cause: String::new(),
139 cause_stack_trace: None,
140 })
141 }
142 }
143 }
144}
145pub mod parser {
146 use crate::protocol::tracks::{PlaylistData, PlaylistInfo, Track, TrackInfo};
147 use serde_json::Value;
148 pub fn parse_track(v: &Value) -> Option<Track> {
149 let id = v.get("id").and_then(|v| {
150 v.as_str()
151 .map(|s| s.to_owned())
152 .or_else(|| v.as_i64().map(|i| i.to_string()))
153 })?;
154 let title = super::helpers::clean_string(
155 v.get("title")
156 .or_else(|| v.get("song"))
157 .and_then(|v| v.as_str())?,
158 );
159 let duration = v
160 .pointer("/more_info/duration")
161 .or_else(|| v.get("duration"))
162 .and_then(|v| v.as_str())
163 .and_then(|s| s.parse::<u64>().ok())
164 .or_else(|| v.pointer("/more_info/duration").and_then(|v| v.as_u64()))
165 .or_else(|| v.get("duration").and_then(|v| v.as_u64()))
166 .unwrap_or(0);
167 let artwork_url = v
168 .get("image")
169 .and_then(|v| v.as_str())
170 .filter(|s| !s.is_empty())
171 .map(|s| s.replace("150x150", "500x500").replace("50x50", "500x500"));
172 let artists_str = v
173 .pointer("/more_info/artistMap/primary_artists")
174 .and_then(|v| v.as_array())
175 .filter(|arr| !arr.is_empty())
176 .or_else(|| {
177 v.pointer("/more_info/artistMap/artists")
178 .and_then(|v| v.as_array())
179 })
180 .map(|arr| {
181 arr.iter()
182 .filter_map(|a| a.get("name").and_then(|v| v.as_str()))
183 .take(3)
184 .collect::<Vec<_>>()
185 .join(", ")
186 })
187 .or_else(|| {
188 v.pointer("/more_info/music")
189 .or_else(|| v.get("subtitle"))
190 .or_else(|| v.get("primary_artists"))
191 .or_else(|| v.get("singers"))
192 .or_else(|| v.get("header_desc"))
193 .and_then(|v| v.as_str())
194 .map(|s| {
195 s.split(',')
196 .map(|part| part.trim())
197 .take(3)
198 .collect::<Vec<_>>()
199 .join(", ")
200 })
201 })
202 .unwrap_or_else(|| "Unknown Artist".to_owned());
203 let author = super::helpers::clean_string(&artists_str);
204 let mut track = Track::new(TrackInfo {
205 title,
206 author,
207 length: duration * 1000,
208 identifier: id,
209 source_name: "jiosaavn".to_owned(),
210 uri: v
211 .get("perma_url")
212 .and_then(|v| v.as_str())
213 .filter(|s| !s.is_empty())
214 .map(|s| s.to_owned()),
215 artwork_url,
216 is_stream: false,
217 is_seekable: true,
218 ..Default::default()
219 });
220 track.plugin_info = serde_json::json!({
221 "albumName": v.get("album").or_else(|| v.pointer("/more_info/album")).and_then(|v| v.as_str()),
222 "albumUrl": v.get("album_url").or_else(|| v.pointer("/more_info/album_url")).and_then(|v| v.as_str()),
223 "artistUrl": v.pointer("/more_info/artistMap/primary_artists/0/perma_url").and_then(|v| v.as_str()),
224 "artistArtworkUrl": v.pointer("/more_info/artistMap/primary_artists/0/image").and_then(|v| v.as_str()).map(|s| s.replace("150x150", "500x500").replace("50x50", "500x500")),
225 "previewUrl": v.get("media_preview_url").or_else(|| v.pointer("/more_info/media_preview_url")).or_else(|| v.get("vlink")).or_else(|| v.pointer("/more_info/vlink")).and_then(|v| v.as_str()),
226 "isPreview": false
227 });
228 Some(track)
229 }
230 pub fn parse_search_item(v: &Value) -> Option<Track> {
231 let id = v.get("id").and_then(|v| v.as_str())?;
232 let title = super::helpers::clean_string(v.get("title").and_then(|v| v.as_str())?);
233 let artwork_url = v
234 .get("image")
235 .and_then(|v| v.as_str())
236 .map(|s| s.replace("150x150", "500x500").replace("50x50", "500x500"));
237 let author_str = v
238 .get("subtitle")
239 .or_else(|| v.get("description"))
240 .and_then(|v| v.as_str())
241 .map(|s| {
242 s.split(',')
243 .map(|part| part.trim())
244 .take(3)
245 .collect::<Vec<_>>()
246 .join(", ")
247 })
248 .unwrap_or_else(|| "Unknown Artist".to_owned());
249 let author = super::helpers::clean_string(&author_str);
250 let mut track = Track::new(TrackInfo {
251 title,
252 author,
253 length: 0,
254 identifier: id.to_owned(),
255 source_name: "jiosaavn".to_owned(),
256 uri: v.get("url").and_then(|v| v.as_str()).map(|s| s.to_owned()),
257 artwork_url,
258 is_stream: false,
259 is_seekable: true,
260 ..Default::default()
261 });
262 track.plugin_info = serde_json::json!({
263 "albumName": v.get("album").and_then(|v| v.as_str()),
264 "previewUrl": v.get("vlink").or_else(|| v.pointer("/more_info/vlink")).and_then(|v| v.as_str()),
265 "isPreview": true
266 });
267 Some(track)
268 }
269 pub fn parse_search_playlist(v: &Value, type_: &str) -> Option<PlaylistData> {
270 let title = super::helpers::clean_string(v.get("title").and_then(|v| v.as_str())?);
271 let artwork_url = v
272 .get("image")
273 .and_then(|v| v.as_str())
274 .map(|s| s.replace("150x150", "500x500").replace("50x50", "500x500"));
275 let mut url = v
276 .get("url")
277 .or_else(|| v.get("perma_url"))
278 .and_then(|v| v.as_str())
279 .unwrap_or("")
280 .to_owned();
281 if url.is_empty() {
282 url = v
283 .get("perma_url")
284 .or_else(|| v.get("permaurl"))
285 .or_else(|| v.get("token"))
286 .or_else(|| v.pointer("/more_info/perma_url"))
287 .or_else(|| v.pointer("/more_info/token"))
288 .and_then(|v| v.as_str())
289 .unwrap_or("")
290 .to_owned();
291 }
292 if url.is_empty()
293 && let Some(id) = v.get("id").and_then(|v| v.as_str())
294 {
295 if id.starts_with('/') || id.starts_with("http") {
296 url = id.to_owned();
297 } else {
298 let path_type = match type_ {
299 "playlist" => "s/playlist",
300 "featured" => "featured",
301 "album" => "album",
302 "artist" => "artist",
303 _ => type_,
304 };
305 let slug = title
306 .to_lowercase()
307 .replace(|c: char| !c.is_alphanumeric() && c != ' ', "")
308 .replace(' ', "-");
309 url = format!("/{path_type}/{slug}/{id}");
310 }
311 }
312 if !url.is_empty() && !url.starts_with("http") {
313 url = format!("https://www.jiosaavn.com{url}");
314 }
315 let total_tracks = v
316 .pointer("/more_info/song_count")
317 .or_else(|| v.pointer("/more_info/track_count"))
318 .or_else(|| v.get("song_count"))
319 .or_else(|| v.get("track_count"))
320 .and_then(|v| {
321 v.as_str()
322 .and_then(|s| s.parse::<u64>().ok())
323 .or_else(|| v.as_u64())
324 })
325 .unwrap_or(0);
326 let author_raw = v
327 .pointer("/more_info/artist_name")
328 .or_else(|| v.pointer("/more_info/music"))
329 .or_else(|| v.get("music"))
330 .or_else(|| v.get("subtitle"))
331 .or_else(|| v.get("description"))
332 .and_then(|v| v.as_str())
333 .map(|s| {
334 s.split(',')
335 .map(|part| part.trim())
336 .take(3)
337 .collect::<Vec<_>>()
338 .join(", ")
339 })
340 .filter(|s| !s.is_empty());
341 let final_author = if type_ == "artist" {
342 title.clone()
343 } else {
344 author_raw.unwrap_or_else(|| "Unknown Author".to_owned())
345 };
346 Some(PlaylistData {
347 info: PlaylistInfo {
348 name: title,
349 selected_track: -1,
350 },
351 plugin_info: serde_json::json!({
352 "url": url,
353 "type": type_,
354 "artworkUrl": artwork_url,
355 "author": super::helpers::clean_string(&final_author),
356 "totalTracks": total_tracks
357 }),
358 tracks: Vec::new(),
359 })
360 }
361}
362pub mod reader {
363 use crate::{
364 audio::source::{HttpSource, create_client},
365 common::types::AnyResult,
366 };
367 use std::io::{Read, Seek, SeekFrom};
368 use symphonia::core::io::MediaSource;
369 pub struct JioSaavnReader {
370 inner: HttpSource,
371 }
372 const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36";
373 impl JioSaavnReader {
374 pub async fn new(
375 url: &str,
376 local_addr: Option<std::net::IpAddr>,
377 proxy: Option<crate::config::HttpProxyConfig>,
378 ) -> AnyResult<Self> {
379 let client = create_client(USER_AGENT.to_owned(), local_addr, proxy, None)?;
380 let inner = HttpSource::new(client, url).await?;
381 Ok(Self { inner })
382 }
383 }
384 impl Read for JioSaavnReader {
385 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
386 self.inner.read(buf)
387 }
388 }
389 impl Seek for JioSaavnReader {
390 fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
391 self.inner.seek(pos)
392 }
393 }
394 impl MediaSource for JioSaavnReader {
395 fn is_seekable(&self) -> bool {
396 self.inner.is_seekable()
397 }
398 fn byte_len(&self) -> Option<u64> {
399 self.inner.byte_len()
400 }
401 }
402}
403pub mod recommendations {
404 use super::{JioSaavnSource, helpers::get_json, parser::parse_track};
405 use crate::protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo};
406 use regex::Regex;
407 impl JioSaavnSource {
408 pub async fn get_recommendations(&self, query: &str) -> LoadResult {
409 let mut id = query.to_owned();
410 let id_regex = Regex::new(r"^[A-Za-z0-9_,-]+$").unwrap();
411 if !id_regex.is_match(query) {
412 if let LoadResult::Search(tracks) = self.search(query).await {
413 if let Some(first) = tracks.first() {
414 id = first.info.identifier.clone();
415 } else {
416 return LoadResult::Empty {};
417 }
418 } else {
419 return LoadResult::Empty {};
420 }
421 }
422 let encoded_id = format!("[\"{id}\"]");
423 let params = vec![
424 ("__call", "webradio.createEntityStation"),
425 ("api_version", "4"),
426 ("_format", "json"),
427 ("_marker", "0"),
428 ("ctx", "android"),
429 ("entity_id", &encoded_id),
430 ("entity_type", "queue"),
431 ];
432 let station_id = get_json(&self.client, &self.api_url, ¶ms)
433 .await
434 .and_then(|json| {
435 json.get("stationid")
436 .and_then(|v| v.as_str())
437 .map(|s| s.to_owned())
438 });
439 if let Some(sid) = station_id {
440 let k_limit = self.recommendations_limit.to_string();
441 let params = vec![
442 ("__call", "webradio.getSong"),
443 ("api_version", "4"),
444 ("_format", "json"),
445 ("_marker", "0"),
446 ("ctx", "android"),
447 ("stationid", &sid),
448 ("k", &k_limit),
449 ];
450 if let Some(json) = get_json(&self.client, &self.api_url, ¶ms).await
451 && let Some(obj) = json.as_object()
452 {
453 let tracks: Vec<_> = obj
454 .values()
455 .filter_map(|v| v.get("song"))
456 .filter_map(parse_track)
457 .collect();
458 if !tracks.is_empty() {
459 return LoadResult::Playlist(PlaylistData {
460 info: PlaylistInfo {
461 name: "JioSaavn Recommendations".to_owned(),
462 selected_track: -1,
463 },
464 plugin_info: serde_json::json!({
465 "type": "recommendations",
466 "totalTracks": tracks.len()
467 }),
468 tracks,
469 });
470 }
471 }
472 }
473 if let Some(metadata) = self.fetch_metadata(&id).await
474 && let Some(artist_ids) =
475 metadata.get("primary_artists_id").and_then(|v| v.as_str())
476 {
477 let params = vec![
478 ("__call", "search.artistOtherTopSongs"),
479 ("api_version", "4"),
480 ("_format", "json"),
481 ("_marker", "0"),
482 ("ctx", "wap6dot0"),
483 ("artist_ids", artist_ids),
484 ("song_id", &id),
485 ("language", "unknown"),
486 ];
487 if let Some(json) = get_json(&self.client, &self.api_url, ¶ms).await
488 && let Some(arr) = json.as_array()
489 {
490 let tracks: Vec<_> = arr
491 .iter()
492 .take(self.recommendations_limit)
493 .filter_map(parse_track)
494 .collect();
495 if !tracks.is_empty() {
496 return LoadResult::Playlist(PlaylistData {
497 info: PlaylistInfo {
498 name: "JioSaavn Recommendations".to_owned(),
499 selected_track: -1,
500 },
501 plugin_info: serde_json::json!({
502 "type": "recommendations",
503 "totalTracks": tracks.len()
504 }),
505 tracks,
506 });
507 }
508 }
509 }
510 LoadResult::Empty {}
511 }
512 }
513}
514pub mod search {
515 use super::{
516 JioSaavnSource,
517 helpers::get_json,
518 parser::{parse_search_item, parse_search_playlist, parse_track},
519 };
520 use crate::protocol::tracks::{LoadError, LoadResult, SearchResult};
521 use tracing::debug;
522 impl JioSaavnSource {
523 pub async fn search(&self, query: &str) -> LoadResult {
524 debug!("JioSaavn searching: {query}");
525 let params = vec![
526 ("__call", "search.getResults"),
527 ("api_version", "4"),
528 ("_format", "json"),
529 ("_marker", "0"),
530 ("cc", "in"),
531 ("ctx", "web6dot0"),
532 ("includeMetaTags", "1"),
533 ("q", query),
534 ];
535 if let Some(json) = get_json(&self.client, &self.api_url, ¶ms).await {
536 if let Some(results) = json.get("results").and_then(|v| v.as_array()) {
537 if results.is_empty() {
538 return LoadResult::Empty {};
539 }
540 let tracks: Vec<_> = results
541 .iter()
542 .take(self.search_limit)
543 .filter_map(parse_track)
544 .collect();
545 return LoadResult::Search(tracks);
546 }
547 LoadResult::Empty {}
548 } else {
549 LoadResult::Error(LoadError {
550 message: Some("JioSaavn search failed".to_owned()),
551 severity: crate::common::Severity::Common,
552 cause: String::new(),
553 cause_stack_trace: None,
554 })
555 }
556 }
557 pub async fn get_autocomplete(
558 &self,
559 query: &str,
560 types: &[String],
561 ) -> Option<SearchResult> {
562 debug!("JioSaavn get_autocomplete: {query}");
563 let params = vec![
564 ("__call", "autocomplete.get"),
565 ("api_version", "4"),
566 ("_format", "json"),
567 ("_marker", "0"),
568 ("ctx", "web6dot0"),
569 ("query", query),
570 ];
571 let json = get_json(&self.client, &self.api_url, ¶ms).await?;
572 let mut tracks = Vec::new();
573 let mut albums = Vec::new();
574 let mut artists = Vec::new();
575 let mut playlists = Vec::new();
576 let texts = Vec::new();
577 let all_types = types.is_empty();
578 if (all_types || types.contains(&"track".to_owned()))
579 && let Some(songs) = json
580 .get("songs")
581 .and_then(|v| v.get("data"))
582 .and_then(|v| v.as_array())
583 {
584 for item in songs {
585 if let Some(track) = parse_search_item(item) {
586 tracks.push(track);
587 }
588 }
589 }
590 if !tracks.is_empty() {
591 let pids: Vec<String> = tracks.iter().map(|t| t.info.identifier.clone()).collect();
592 let pids_str = pids.join(",");
593 let details_params = vec![
594 ("__call", "song.getDetails"),
595 ("_format", "json"),
596 ("pids", &pids_str),
597 ];
598 if let Some(details_json) =
599 get_json(&self.client, &self.api_url, &details_params).await
600 {
601 for track in &mut tracks {
602 if let Some(detail) = details_json.get(&track.info.identifier) {
603 if let Some(duration) = detail
604 .get("duration")
605 .and_then(|v| v.as_str())
606 .and_then(|s| s.parse::<u64>().ok())
607 .or_else(|| detail.get("duration").and_then(|v| v.as_u64()))
608 {
609 track.info.length = duration * 1000;
610 }
611 if let Some(perma_url) =
612 detail.get("perma_url").and_then(|v| v.as_str())
613 {
614 track.info.uri = Some(perma_url.to_owned());
615 }
616 track.plugin_info = serde_json::json!({
617 "albumName": detail
618 .get("album")
619 .or_else(|| detail.pointer("/more_info/album"))
620 .and_then(|v| v.as_str()),
621 "albumUrl": detail
622 .get("album_url")
623 .or_else(|| detail.pointer("/more_info/album_url"))
624 .and_then(|v| v.as_str()),
625 "artistUrl": detail
626 .pointer("/more_info/artistMap/primary_artists/0/perma_url")
627 .and_then(|v| v.as_str()),
628 "artistArtworkUrl": detail
629 .pointer("/more_info/artistMap/primary_artists/0/image")
630 .and_then(|v| v.as_str())
631 .map(|s| s.replace("150x150", "500x500").replace("50x50", "500x500")),
632 "previewUrl": detail
633 .get("media_preview_url")
634 .or_else(|| detail.pointer("/more_info/media_preview_url"))
635 .or_else(|| detail.get("vlink"))
636 .or_else(|| detail.pointer("/more_info/vlink"))
637 .and_then(|v| v.as_str()),
638 "isPreview": false
639 });
640 if let Some(artists) =
641 detail.get("primary_artists").and_then(|v| v.as_str())
642 && !artists.is_empty()
643 {
644 let limited_artists = artists
645 .split(',')
646 .map(|s| s.trim())
647 .take(3)
648 .collect::<Vec<_>>()
649 .join(", ");
650 track.info.author = super::helpers::clean_string(&limited_artists);
651 }
652 track.encoded = track.encode();
653 }
654 }
655 }
656 }
657 if (all_types || types.contains(&"album".to_owned()))
658 && let Some(data) = json
659 .get("albums")
660 .and_then(|v| v.get("data"))
661 .and_then(|v| v.as_array())
662 {
663 for item in data {
664 if let Some(pd) = parse_search_playlist(item, "album") {
665 albums.push(pd);
666 }
667 }
668 }
669 if (all_types || types.contains(&"artist".to_owned()))
670 && let Some(data) = json
671 .get("artists")
672 .and_then(|v| v.get("data"))
673 .and_then(|v| v.as_array())
674 {
675 for item in data {
676 if let Some(pd) = parse_search_playlist(item, "artist") {
677 artists.push(pd);
678 }
679 }
680 }
681 if (all_types || types.contains(&"playlist".to_owned()))
682 && let Some(data) = json
683 .get("playlists")
684 .and_then(|v| v.get("data"))
685 .and_then(|v| v.as_array())
686 {
687 for item in data {
688 if let Some(pd) = parse_search_playlist(item, "playlist") {
689 playlists.push(pd);
690 }
691 }
692 }
693 if (all_types || types.is_empty())
694 && let Some(top_data) = json
695 .get("topquery")
696 .and_then(|v| v.get("data"))
697 .and_then(|v| v.as_array())
698 {
699 for item in top_data {
700 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
701 match item_type {
702 "song" => {
703 if let Some(track) = parse_search_item(item)
704 && !tracks
705 .iter()
706 .any(|t| t.info.identifier == track.info.identifier)
707 {
708 tracks.insert(0, track);
709 }
710 }
711 "album" => {
712 if let Some(pd) = parse_search_playlist(item, "album")
713 && !albums.iter().any(|a| a.info.name == pd.info.name)
714 {
715 albums.insert(0, pd);
716 }
717 }
718 "artist" => {
719 if let Some(pd) = parse_search_playlist(item, "artist")
720 && !artists.iter().any(|a| a.info.name == pd.info.name)
721 {
722 artists.insert(0, pd);
723 }
724 }
725 "playlist" => {
726 if let Some(pd) = parse_search_playlist(item, "playlist")
727 && !playlists.iter().any(|a| a.info.name == pd.info.name)
728 {
729 playlists.insert(0, pd);
730 }
731 }
732 _ => {}
733 }
734 }
735 }
736 Some(SearchResult {
737 tracks,
738 albums,
739 artists,
740 playlists,
741 texts,
742 plugin: serde_json::json!({}),
743 })
744 }
745 }
746}
747pub mod track {
748 use crate::{
749 common::AudioFormat,
750 config::HttpProxyConfig,
751 sources::playable_track::{PlayableTrack, ResolvedTrack},
752 };
753 use async_trait::async_trait;
754 use base64::prelude::*;
755 use des::{
756 Des,
757 cipher::{BlockDecrypt, KeyInit, generic_array::GenericArray},
758 };
759 use std::net::IpAddr;
760 pub struct JioSaavnTrack {
761 pub encrypted_url: String,
762 pub secret_key: Vec<u8>,
763 pub is_320: bool,
764 pub local_addr: Option<IpAddr>,
765 pub proxy: Option<HttpProxyConfig>,
766 }
767 #[async_trait]
768 impl PlayableTrack for JioSaavnTrack {
769 async fn resolve(&self) -> Result<ResolvedTrack, String> {
770 let url = self.resolve_url().ok_or_else(|| {
771 "Failed to decrypt JioSaavn URL. Check secretKey in config.toml".to_string()
772 })?;
773 let hint = format_hint_from_url(&url);
774 let reader =
775 super::reader::JioSaavnReader::new(&url, self.local_addr, self.proxy.clone())
776 .await
777 .map(|r| Box::new(r) as Box<dyn symphonia::core::io::MediaSource>)
778 .map_err(|e| format!("Failed to open stream: {e}"))?;
779 Ok(ResolvedTrack::new(reader, hint))
780 }
781 }
782 impl JioSaavnTrack {
783 fn resolve_url(&self) -> Option<String> {
784 let mut url = self.decrypt_url(&self.encrypted_url)?;
785 if self.is_320 {
786 url = url.replace("_96.mp4", "_320.mp4");
787 }
788 Some(url)
789 }
790 fn decrypt_url(&self, encrypted: &str) -> Option<String> {
791 if self.secret_key.len() != 8 {
792 return None;
793 }
794 let cipher = Des::new_from_slice(&self.secret_key).ok()?;
795 let mut data = BASE64_STANDARD.decode(encrypted).ok()?;
796 for chunk in data.chunks_mut(8) {
797 if chunk.len() == 8 {
798 cipher.decrypt_block(GenericArray::from_mut_slice(chunk));
799 }
800 }
801 if let Some(&last_byte) = data.last() {
802 let padding = last_byte as usize;
803 if (1..=8).contains(&padding) && data.len() >= padding {
804 data.truncate(data.len() - padding);
805 }
806 }
807 String::from_utf8(data).ok()
808 }
809 }
810 fn format_hint_from_url(url: &str) -> Option<AudioFormat> {
811 std::path::Path::new(url)
812 .extension()
813 .and_then(|s| s.to_str())
814 .map(AudioFormat::from_ext)
815 .filter(|f| *f != AudioFormat::Unknown)
816 .or(Some(AudioFormat::Mp4))
817 }
818}
819use self::track::JioSaavnTrack;
820use crate::{protocol::tracks::LoadResult, sources::playable_track::BoxedTrack};
821use async_trait::async_trait;
822use regex::Regex;
823use std::sync::{Arc, OnceLock};
824fn url_regex() -> &'static Regex {
825 static REGEX: OnceLock<Regex> = OnceLock::new();
826 REGEX.get_or_init(|| {
827 Regex::new(r"https?://(?:www\.)?(?:jiosaavn|saavn)\.com/(?:(?<type>p/album|s/featured|s/artist|s/song|album|featured|song|s/playlist|artist)/)(?:[^/]+/)+(?<id>[A-Za-z0-9_,-]+)").unwrap()
828 })
829}
830pub struct JioSaavnSource {
831 pub(crate) client: Arc<reqwest::Client>,
832 pub(crate) secret_key: Vec<u8>,
833 pub(crate) proxy: Option<crate::config::HttpProxyConfig>,
834 pub(crate) api_url: String,
835 pub(crate) search_limit: usize,
836 pub(crate) recommendations_limit: usize,
837 pub(crate) playlist_load_limit: usize,
838 pub(crate) album_load_limit: usize,
839 pub(crate) artist_load_limit: usize,
840}
841impl JioSaavnSource {
842 pub fn new(
843 config: Option<crate::config::JioSaavnConfig>,
844 client: Arc<reqwest::Client>,
845 ) -> Result<Self, String> {
846 let (
847 secret_key,
848 search_limit,
849 recommendations_limit,
850 playlist_load_limit,
851 album_load_limit,
852 artist_load_limit,
853 proxy,
854 api_url,
855 ) = if let Some(c) = config {
856 (
857 c.decryption
858 .and_then(|d| d.secret_key)
859 .unwrap_or_else(|| "38346591".to_owned()),
860 c.search_limit,
861 c.recommendations_limit,
862 c.playlist_load_limit,
863 c.album_load_limit,
864 c.artist_load_limit,
865 c.proxy,
866 c.api_url
867 .unwrap_or_else(|| "https://www.jiosaavn.com/api.php".to_owned()),
868 )
869 } else {
870 (
871 "38346591".to_owned(),
872 10,
873 10,
874 50,
875 50,
876 20,
877 None,
878 "https://www.jiosaavn.com/api.php".to_owned(),
879 )
880 };
881 Ok(Self {
882 client,
883 secret_key: secret_key.into_bytes(),
884 proxy,
885 search_limit,
886 recommendations_limit,
887 playlist_load_limit,
888 album_load_limit,
889 artist_load_limit,
890 api_url,
891 })
892 }
893}
894#[async_trait]
895impl crate::sources::plugin::SourcePlugin for JioSaavnSource {
896 fn name(&self) -> &str {
897 "jiosaavn"
898 }
899 fn can_handle(&self, identifier: &str) -> bool {
900 self.search_prefixes()
901 .iter()
902 .any(|p| identifier.starts_with(p))
903 || self
904 .rec_prefixes()
905 .iter()
906 .any(|p| identifier.starts_with(p))
907 || url_regex().is_match(identifier)
908 }
909 fn search_prefixes(&self) -> Vec<&str> {
910 vec!["jssearch:"]
911 }
912 fn rec_prefixes(&self) -> Vec<&str> {
913 vec!["jsrec:"]
914 }
915 async fn load(
916 &self,
917 identifier: &str,
918 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
919 ) -> LoadResult {
920 for prefix in self.rec_prefixes() {
921 if let Some(query) = identifier.strip_prefix(prefix) {
922 return self.get_recommendations(query).await;
923 }
924 }
925 for prefix in self.search_prefixes() {
926 if let Some(query) = identifier.strip_prefix(prefix) {
927 return self.search(query).await;
928 }
929 }
930 if let Some(caps) = url_regex().captures(identifier) {
931 let type_ = caps.name("type").map(|m| m.as_str()).unwrap_or("");
932 let id = caps.name("id").map(|m| m.as_str()).unwrap_or("");
933 if id.is_empty() || type_.is_empty() {
934 return LoadResult::Empty {};
935 }
936 let canonical_type = match type_ {
937 "p/album" => "album",
938 "s/artist" => "artist",
939 "s/featured" => "featured",
940 other => other,
941 };
942 if canonical_type == "song" || canonical_type == "s/song" {
943 if let Some(track_data) = self.fetch_metadata(id).await {
944 if let Some(track) = parser::parse_track(&track_data) {
945 return LoadResult::Track(track);
946 }
947 }
948 return LoadResult::Empty {};
949 } else {
950 return self.resolve_list(canonical_type, id).await;
951 }
952 }
953 LoadResult::Empty {}
954 }
955 async fn get_track(
956 &self,
957 identifier: &str,
958 routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
959 ) -> Option<BoxedTrack> {
960 let id = if let Some(caps) = url_regex().captures(identifier) {
961 caps.name("id").map(|m| m.as_str()).unwrap_or(identifier)
962 } else {
963 identifier
964 };
965 let track_data = self.fetch_metadata(id).await?;
966 let encrypted_url = track_data
967 .get("more_info")
968 .and_then(|m| m.get("encrypted_media_url"))
969 .and_then(|v| v.as_str())?
970 .to_owned();
971 let is_320 = track_data
972 .get("more_info")
973 .and_then(|m| m.get("320kbps"))
974 .map(|v| v.as_str() == Some("true") || v.as_bool() == Some(true))
975 .unwrap_or(false);
976 let local_addr = routeplanner.and_then(|rp| rp.get_address());
977 Some(Arc::new(JioSaavnTrack {
978 encrypted_url,
979 secret_key: self.secret_key.clone(),
980 is_320,
981 local_addr,
982 proxy: self.proxy.clone(),
983 }))
984 }
985 fn get_proxy_config(&self) -> Option<crate::config::HttpProxyConfig> {
986 self.proxy.clone()
987 }
988 async fn load_search(
989 &self,
990 query: &str,
991 types: &[String],
992 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
993 ) -> Option<crate::protocol::tracks::SearchResult> {
994 let mut q = query;
995 for prefix in self.search_prefixes() {
996 if let Some(stripped) = query.strip_prefix(prefix) {
997 q = stripped;
998 break;
999 }
1000 }
1001 self.get_autocomplete(q, types).await
1002 }
1003}