Skip to main content

lavende_core/sources/
yandexmusic.rs

1pub mod track {
2    use super::utils;
3    use crate::{
4        config::HttpProxyConfig,
5        sources::{
6            http::HttpTrack,
7            playable_track::{PlayableTrack, ResolvedTrack},
8        },
9    };
10    use async_trait::async_trait;
11    use regex::Regex;
12    use std::{net::IpAddr, sync::Arc};
13    use tracing::debug;
14    pub struct YandexMusicTrack {
15        pub client: Arc<reqwest::Client>,
16        pub track_id: String,
17        pub local_addr: Option<IpAddr>,
18        pub proxy: Option<HttpProxyConfig>,
19    }
20    #[async_trait]
21    impl PlayableTrack for YandexMusicTrack {
22        async fn resolve(&self) -> Result<ResolvedTrack, String> {
23            let stream_url = fetch_download_url(&self.client, &self.track_id)
24                .await
25                .ok_or_else(|| {
26                    format!(
27                        "Failed to fetch Yandex Music stream URL for track ID {}",
28                        self.track_id
29                    )
30                })?;
31            debug!("Yandex Music stream URL: {}", stream_url);
32            let http_track = HttpTrack {
33                url: stream_url,
34                local_addr: self.local_addr,
35                proxy: self.proxy.clone(),
36            };
37            http_track.resolve().await
38        }
39    }
40    pub(super) async fn fetch_download_url(
41        client: &Arc<reqwest::Client>,
42        id: &str,
43    ) -> Option<String> {
44        let url = format!("https://api.music.yandex.net/tracks/{}/download-info", id);
45        let resp = client.get(url).send().await.ok()?;
46        let data: serde_json::Value = resp.json().await.ok()?;
47        let results = data["result"].as_array()?;
48        let mut mp3_items: Vec<_> = results
49            .iter()
50            .filter(|item| item["codec"].as_str() == Some("mp3"))
51            .collect();
52        mp3_items.sort_by_key(|item| item["bitrateInKbps"].as_u64().unwrap_or(0));
53        let best_mp3 = mp3_items.last()?;
54        let download_info_url = best_mp3["downloadInfoUrl"].as_str()?;
55        let xml_resp = client.get(download_info_url).send().await.ok()?;
56        let xml_text = xml_resp.text().await.ok()?;
57        let get_tag = |text: &str, tag: &str| -> Option<String> {
58            let pattern = format!("<{tag}>(?P<val>[^<]+)</{tag}>");
59            let re = Regex::new(&pattern).ok()?;
60            re.captures(text)?.name("val")?.as_str().to_string().into()
61        };
62        let host = get_tag(&xml_text, "host")?;
63        let path = get_tag(&xml_text, "path")?;
64        let ts = get_tag(&xml_text, "ts")?;
65        let s = get_tag(&xml_text, "s")?;
66        let md5 = utils::generate_download_sign(&path, &s);
67        Some(format!("https://{}/get-mp3/{}/{}{}", host, md5, ts, path))
68    }
69}
70pub mod utils {
71    use md5::{Digest, Md5};
72    pub fn generate_download_sign(path: &str, s: &str) -> String {
73        let sign = format!("XGRlBW9FXlekgbPrRHuSiA{}{}", path, s);
74        let mut hasher = Md5::new();
75        hasher.update(sign.as_bytes());
76        let result = hasher.finalize();
77        format!("{:x}", result)
78    }
79}
80use crate::{
81    protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
82    sources::{SourcePlugin, playable_track::BoxedTrack},
83};
84use async_trait::async_trait;
85use regex::Regex;
86use serde_json::{Value, json};
87use std::sync::Arc;
88use tracing::debug;
89pub struct YandexMusicSource {
90    client: Arc<reqwest::Client>,
91    access_token: String,
92    url_pattern: Regex,
93    playlist_pattern: Regex,
94    playlist_uuid_pattern: Regex,
95    search_limit: usize,
96    playlist_load_limit: usize,
97    album_load_limit: usize,
98    artist_load_limit: usize,
99    proxy: Option<crate::config::HttpProxyConfig>,
100}
101const API_BASE: &str = "https://api.music.yandex.net";
102impl YandexMusicSource {
103    pub fn new(
104        config: Option<crate::config::YandexMusicConfig>,
105        client: Arc<reqwest::Client>,
106    ) -> Result<Self, String> {
107        let config = config.ok_or("Yandex Music configuration is missing")?;
108        let access_token = config
109            .access_token
110            .clone()
111            .ok_or("Yandex Music access token is missing")?;
112        Ok(Self {
113            client,
114            access_token,
115            url_pattern: Regex::new(r"(?i)^https?://music\.yandex\.(ru|com|kz|by)/(artist|album|track)/(?P<id1>[0-9]+)(/(track)/(?P<id2>[0-9]+))?/?").unwrap(),
116            playlist_pattern: Regex::new(r"(?i)^https?://music\.yandex\.(ru|com|kz|by)/users/(?P<user>[^/]+)/playlists/(?P<id>[0-9]+)/?").unwrap(),
117            playlist_uuid_pattern: Regex::new(r"(?i)^https?://music\.yandex\.(ru|com|kz|by)/playlists/(?P<uuid>[0-9a-z-]+)").unwrap(),
118            search_limit: config.search_limit,
119            playlist_load_limit: config.playlist_load_limit,
120            album_load_limit: config.album_load_limit,
121            artist_load_limit: config.artist_load_limit,
122            proxy: config.proxy,
123        })
124    }
125    async fn api_request(&self, endpoint: &str, params: Option<&[(&str, &str)]>) -> Option<Value> {
126        let mut url = format!("{}{}", API_BASE, endpoint);
127        if let Some(p) = params {
128            let mut first = true;
129            for (k, v) in p {
130                url.push_str(if first { "?" } else { "&" });
131                url.push_str(&format!("{}={}", k, urlencoding::encode(v)));
132                first = false;
133            }
134        }
135        debug!("Yandex Music API request: {}", url);
136        let builder = self.client.get(&url);
137        let resp = self.base_request(builder).send().await.ok()?;
138        let status = resp.status();
139        debug!("Yandex Music API response status: {} -> {}", url, status);
140        if !status.is_success() {
141            debug!("Yandex Music API request failed: {} -> {}", url, status);
142            return None;
143        }
144        let body: Value = resp.json().await.ok()?;
145        debug!("Yandex Music API response body: {}", body);
146        Some(body["result"].clone())
147    }
148    async fn search(&self, query: &str) -> LoadResult {
149        let data = match self
150            .api_request(
151                "/search",
152                Some(&[("text", query), ("type", "all"), ("page", "0")]),
153            )
154            .await
155        {
156            Some(d) => d,
157            None => return LoadResult::Empty {},
158        };
159        let tracks = self.parse_tracks(&data["tracks"]["results"]);
160        if tracks.is_empty() {
161            return LoadResult::Empty {};
162        }
163        LoadResult::Search(tracks)
164    }
165    async fn load_search_internal(
166        &self,
167        query: &str,
168    ) -> Option<crate::protocol::tracks::SearchResult> {
169        let data = self
170            .api_request(
171                "/search",
172                Some(&[("text", query), ("type", "all"), ("page", "0")]),
173            )
174            .await?;
175        let tracks = self.parse_tracks(&data["tracks"]["results"]);
176        let mut albums = Vec::new();
177        if let Some(arr) = data["albums"]["results"].as_array() {
178            for item in arr.iter().take(self.search_limit) {
179                if let Some(playlist) = self.build_playlist_from_search(item, "album") {
180                    albums.push(playlist);
181                }
182            }
183        }
184        let mut artists = Vec::new();
185        if let Some(arr) = data["artists"]["results"].as_array() {
186            for item in arr.iter().take(self.search_limit) {
187                if let Some(playlist) = self.build_playlist_from_search(item, "artist") {
188                    artists.push(playlist);
189                }
190            }
191        }
192        let mut playlists = Vec::new();
193        if let Some(arr) = data["playlists"]["results"].as_array() {
194            for item in arr.iter().take(self.search_limit) {
195                if let Some(playlist) = self.build_playlist_from_search(item, "playlist") {
196                    playlists.push(playlist);
197                }
198            }
199        }
200        Some(crate::protocol::tracks::SearchResult {
201            tracks,
202            albums,
203            artists,
204            playlists,
205            texts: Vec::new(),
206            plugin: json!({}),
207        })
208    }
209    fn build_playlist_from_search(&self, item: &Value, r#type: &str) -> Option<PlaylistData> {
210        if !item["available"].as_bool().unwrap_or(false) {
211            return None;
212        }
213        let name = match r#type {
214            "artist" => item["name"].as_str()?.to_string(),
215            _ => item["title"].as_str()?.to_string(),
216        };
217        Some(PlaylistData {
218            info: PlaylistInfo {
219                name,
220                selected_track: -1,
221            },
222            plugin_info: json!({ "type": r#type }),
223            tracks: Vec::new(),
224        })
225    }
226    async fn resolve_url(&self, url: &str) -> LoadResult {
227        if let Some(caps) = self.playlist_pattern.captures(url) {
228            let user = caps.name("user").map(|m| m.as_str()).unwrap();
229            let id = caps.name("id").map(|m| m.as_str()).unwrap();
230            return self.get_playlist(user, id).await;
231        }
232        if let Some(caps) = self.playlist_uuid_pattern.captures(url) {
233            let uuid = caps.name("uuid").map(|m| m.as_str()).unwrap();
234            return self.get_playlist_uuid(uuid).await;
235        }
236        if let Some(caps) = self.url_pattern.captures(url) {
237            let type1 = caps.get(2).map(|m| m.as_str()).unwrap();
238            let id1 = caps.name("id1").map(|m| m.as_str()).unwrap();
239            match type1 {
240                "track" => return self.get_track_internal(id1).await,
241                "album" => {
242                    if let Some(id2) = caps.name("id2") {
243                        return self.get_track_internal(id2.as_str()).await;
244                    }
245                    return self.get_album(id1).await;
246                }
247                "artist" => return self.get_artist(id1).await,
248                _ => {}
249            }
250        }
251        LoadResult::Empty {}
252    }
253    async fn get_track_internal(&self, id: &str) -> LoadResult {
254        let data = match self.api_request(&format!("/tracks/{}", id), None).await {
255            Some(d) => d,
256            None => return LoadResult::Empty {},
257        };
258        if let Some(track) = self.build_track(&data[0]) {
259            LoadResult::Track(track)
260        } else {
261            LoadResult::Empty {}
262        }
263    }
264    async fn get_album(&self, id: &str) -> LoadResult {
265        let page_size = (self.album_load_limit * 50).max(50).to_string();
266        let data = match self
267            .api_request(
268                &format!("/albums/{}/with-tracks", id),
269                Some(&[("page-size", &page_size)]),
270            )
271            .await
272        {
273            Some(d) => d,
274            None => return LoadResult::Empty {},
275        };
276        let mut tracks = Vec::new();
277        if let Some(volumes) = data["volumes"].as_array() {
278            for volume in volumes {
279                if let Some(arr) = volume.as_array() {
280                    for item in arr {
281                        if let Some(track) = self.build_track(item) {
282                            tracks.push(track);
283                        }
284                    }
285                }
286            }
287        }
288        if tracks.is_empty() {
289            return LoadResult::Empty {};
290        }
291        LoadResult::Playlist(PlaylistData {
292            info: PlaylistInfo {
293                name: data["title"]
294                    .as_str()
295                    .unwrap_or("Yandex Music Album")
296                    .to_string(),
297                selected_track: -1,
298            },
299            plugin_info: json!({ "type": "album" }),
300            tracks,
301        })
302    }
303    async fn get_artist(&self, id: &str) -> LoadResult {
304        let page_size = (self.artist_load_limit * 10).max(10).to_string();
305        let data = match self
306            .api_request(
307                &format!("/artists/{}/tracks", id),
308                Some(&[("page-size", &page_size)]),
309            )
310            .await
311        {
312            Some(d) => d,
313            None => return LoadResult::Empty {},
314        };
315        let tracks = self.parse_tracks(&data["tracks"]);
316        if tracks.is_empty() {
317            return LoadResult::Empty {};
318        }
319        let artist_data = match self.api_request(&format!("/artists/{}", id), None).await {
320            Some(d) => d,
321            None => return LoadResult::Empty {},
322        };
323        let name = artist_data["artist"]["name"].as_str().unwrap_or("Artist");
324        LoadResult::Playlist(PlaylistData {
325            info: PlaylistInfo {
326                name: format!("{}'s Top Tracks", name),
327                selected_track: -1,
328            },
329            plugin_info: json!({ "type": "artist" }),
330            tracks,
331        })
332    }
333    async fn get_playlist(&self, user: &str, id: &str) -> LoadResult {
334        let page_size = (self.playlist_load_limit * 100).max(100).to_string();
335        let data = match self
336            .api_request(
337                &format!("/users/{}/playlists/{}", user, id),
338                Some(&[("page-size", &page_size), ("rich-tracks", "true")]),
339            )
340            .await
341        {
342            Some(d) => d,
343            None => return LoadResult::Empty {},
344        };
345        self.build_playlist_result(data)
346    }
347    async fn get_playlist_uuid(&self, uuid: &str) -> LoadResult {
348        let page_size = (self.playlist_load_limit * 100).max(100).to_string();
349        let data = match self
350            .api_request(
351                &format!("/playlist/{}", uuid),
352                Some(&[("page-size", &page_size), ("rich-tracks", "true")]),
353            )
354            .await
355        {
356            Some(d) => d,
357            None => return LoadResult::Empty {},
358        };
359        self.build_playlist_result(data)
360    }
361    async fn get_recommendations(&self, id: &str) -> LoadResult {
362        if !id.chars().all(|c| c.is_ascii_digit()) {
363            return LoadResult::Empty {};
364        }
365        let data = match self
366            .api_request(&format!("/tracks/{}/similar", id), None)
367            .await
368        {
369            Some(d) => d,
370            None => return LoadResult::Empty {},
371        };
372        let tracks = self.parse_tracks(&data["similarTracks"]);
373        if tracks.is_empty() {
374            return LoadResult::Empty {};
375        }
376        LoadResult::Playlist(PlaylistData {
377            info: PlaylistInfo {
378                name: "Yandex Music Recommendations".to_string(),
379                selected_track: -1,
380            },
381            plugin_info: json!({ "type": "recommendations" }),
382            tracks,
383        })
384    }
385    fn build_playlist_result(&self, data: Value) -> LoadResult {
386        let tracks = self.parse_tracks(&data["tracks"]);
387        if tracks.is_empty() {
388            return LoadResult::Empty {};
389        }
390        let title = if data["kind"].as_u64() == Some(3) {
391            let owner = data["owner"]["name"]
392                .as_str()
393                .or(data["owner"]["login"].as_str())
394                .unwrap_or("User");
395            format!("{}'s liked songs", owner)
396        } else {
397            data["title"]
398                .as_str()
399                .unwrap_or("Yandex Music Playlist")
400                .to_string()
401        };
402        LoadResult::Playlist(PlaylistData {
403            info: PlaylistInfo {
404                name: title,
405                selected_track: -1,
406            },
407            plugin_info: json!({ "type": "playlist" }),
408            tracks,
409        })
410    }
411    fn parse_tracks(&self, data: &Value) -> Vec<Track> {
412        let mut tracks = Vec::new();
413        if let Some(arr) = data.as_array() {
414            for item in arr {
415                let track_json = if item.get("track").is_some() {
416                    &item["track"]
417                } else {
418                    item
419                };
420                if let Some(track) = self.build_track(track_json) {
421                    tracks.push(track);
422                }
423            }
424        }
425        tracks
426    }
427    fn build_track(&self, data: &Value) -> Option<Track> {
428        if !data["available"].as_bool().unwrap_or(false) {
429            return None;
430        }
431        let id = data["id"]
432            .as_u64()
433            .map(|n| n.to_string())
434            .or(data["id"].as_str().map(|s| s.to_string()))?;
435        let title = data["title"].as_str()?;
436        let author = self.parse_artist(data);
437        let duration = data["durationMs"].as_u64().unwrap_or(0);
438        let uri = Some(format!("https://music.yandex.ru/track/{}", id));
439        let artwork_url = self.parse_cover_uri(data);
440        Some(Track::new(TrackInfo {
441            identifier: id,
442            is_seekable: true,
443            author,
444            length: duration,
445            is_stream: false,
446            position: 0,
447            title: title.to_string(),
448            uri,
449            artwork_url,
450            isrc: data["isrc"].as_str().map(|s| s.to_string()),
451            source_name: "yandexmusic".to_string(),
452        }))
453    }
454    fn parse_artist(&self, data: &Value) -> String {
455        if let Some(arr) = data["artists"].as_array() {
456            return arr
457                .iter()
458                .filter_map(|a| a["name"].as_str())
459                .collect::<Vec<_>>()
460                .join(", ");
461        }
462        "Unknown Artist".to_string()
463    }
464    fn parse_cover_uri(&self, data: &Value) -> Option<String> {
465        let uri = data["ogImage"]
466            .as_str()
467            .or(data["coverUri"].as_str())
468            .or(data["cover"]["uri"].as_str())?;
469        Some(format!("https://{}", uri.replace("%%", "400x400")))
470    }
471    fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
472        builder
473            .header(reqwest::header::USER_AGENT, "Yandex-Music-API")
474            .header("X-Yandex-Music-Client", "YandexMusicAndroid/24023621")
475            .header(
476                reqwest::header::AUTHORIZATION,
477                format!("OAuth {}", self.access_token),
478            )
479    }
480}
481#[async_trait]
482impl SourcePlugin for YandexMusicSource {
483    fn name(&self) -> &str {
484        "yandexmusic"
485    }
486    fn can_handle(&self, identifier: &str) -> bool {
487        self.search_prefixes()
488            .iter()
489            .any(|p| identifier.starts_with(p))
490            || self
491                .rec_prefixes()
492                .iter()
493                .any(|p| identifier.starts_with(p))
494            || self.url_pattern.is_match(identifier)
495            || self.playlist_pattern.is_match(identifier)
496            || self.playlist_uuid_pattern.is_match(identifier)
497    }
498    fn search_prefixes(&self) -> Vec<&str> {
499        vec!["ymsearch:"]
500    }
501    fn rec_prefixes(&self) -> Vec<&str> {
502        vec!["ymrec:"]
503    }
504    async fn load(
505        &self,
506        identifier: &str,
507        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
508    ) -> LoadResult {
509        if let Some(prefix) = self
510            .search_prefixes()
511            .into_iter()
512            .find(|p| identifier.starts_with(p))
513        {
514            return self.search(identifier.strip_prefix(prefix).unwrap()).await;
515        }
516        if let Some(prefix) = self
517            .rec_prefixes()
518            .into_iter()
519            .find(|p| identifier.starts_with(p))
520        {
521            return self
522                .get_recommendations(identifier.strip_prefix(prefix).unwrap())
523                .await;
524        }
525        self.resolve_url(identifier).await
526    }
527    async fn load_search(
528        &self,
529        query: &str,
530        _types: &[String],
531        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
532    ) -> Option<crate::protocol::tracks::SearchResult> {
533        self.load_search_internal(query).await
534    }
535    async fn get_track(
536        &self,
537        identifier: &str,
538        routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
539    ) -> Option<BoxedTrack> {
540        let track_id = if identifier.starts_with("http") {
541            if let Some(caps) = self.url_pattern.captures(identifier) {
542                caps.name("id2")
543                    .or(caps.name("id1"))
544                    .map(|m| m.as_str().to_string())?
545            } else {
546                return None;
547            }
548        } else {
549            identifier.to_string()
550        };
551        let stream_url = track::fetch_download_url(&self.client, &track_id).await;
552        if stream_url.is_none() {
553            debug!(
554                "Yandex Music: no stream URL for track {}, falling back to mirrors",
555                track_id
556            );
557            return None;
558        }
559        Some(Arc::new(track::YandexMusicTrack {
560            client: self.client.clone(),
561            track_id,
562            local_addr: routeplanner.and_then(|rp| rp.get_address()),
563            proxy: self.proxy.clone(),
564        }))
565    }
566    fn get_proxy_config(&self) -> Option<crate::config::HttpProxyConfig> {
567        self.proxy.clone()
568    }
569}