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}