pub mod track {
use super::utils;
use crate::{
config::HttpProxyConfig,
sources::{
http::HttpTrack,
playable_track::{PlayableTrack, ResolvedTrack},
},
};
use async_trait::async_trait;
use regex::Regex;
use std::{net::IpAddr, sync::Arc};
use tracing::debug;
pub struct YandexMusicTrack {
pub client: Arc<reqwest::Client>,
pub track_id: String,
pub local_addr: Option<IpAddr>,
pub proxy: Option<HttpProxyConfig>,
}
#[async_trait]
impl PlayableTrack for YandexMusicTrack {
async fn resolve(&self) -> Result<ResolvedTrack, String> {
let stream_url = fetch_download_url(&self.client, &self.track_id)
.await
.ok_or_else(|| {
format!(
"Failed to fetch Yandex Music stream URL for track ID {}",
self.track_id
)
})?;
debug!("Yandex Music stream URL: {}", stream_url);
let http_track = HttpTrack {
url: stream_url,
local_addr: self.local_addr,
proxy: self.proxy.clone(),
};
http_track.resolve().await
}
}
pub(super) async fn fetch_download_url(
client: &Arc<reqwest::Client>,
id: &str,
) -> Option<String> {
let url = format!("https://api.music.yandex.net/tracks/{}/download-info", id);
let resp = client.get(url).send().await.ok()?;
let data: serde_json::Value = resp.json().await.ok()?;
let results = data["result"].as_array()?;
let mut mp3_items: Vec<_> = results
.iter()
.filter(|item| item["codec"].as_str() == Some("mp3"))
.collect();
mp3_items.sort_by_key(|item| item["bitrateInKbps"].as_u64().unwrap_or(0));
let best_mp3 = mp3_items.last()?;
let download_info_url = best_mp3["downloadInfoUrl"].as_str()?;
let xml_resp = client.get(download_info_url).send().await.ok()?;
let xml_text = xml_resp.text().await.ok()?;
let get_tag = |text: &str, tag: &str| -> Option<String> {
let pattern = format!("<{tag}>(?P<val>[^<]+)</{tag}>");
let re = Regex::new(&pattern).ok()?;
re.captures(text)?.name("val")?.as_str().to_string().into()
};
let host = get_tag(&xml_text, "host")?;
let path = get_tag(&xml_text, "path")?;
let ts = get_tag(&xml_text, "ts")?;
let s = get_tag(&xml_text, "s")?;
let md5 = utils::generate_download_sign(&path, &s);
Some(format!("https://{}/get-mp3/{}/{}{}", host, md5, ts, path))
}
}
pub mod utils {
use md5::{Digest, Md5};
pub fn generate_download_sign(path: &str, s: &str) -> String {
let sign = format!("XGRlBW9FXlekgbPrRHuSiA{}{}", path, s);
let mut hasher = Md5::new();
hasher.update(sign.as_bytes());
let result = hasher.finalize();
format!("{:x}", result)
}
}
use crate::{
protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
sources::{SourcePlugin, playable_track::BoxedTrack},
};
use async_trait::async_trait;
use regex::Regex;
use serde_json::{Value, json};
use std::sync::Arc;
use tracing::debug;
pub struct YandexMusicSource {
client: Arc<reqwest::Client>,
access_token: String,
url_pattern: Regex,
playlist_pattern: Regex,
playlist_uuid_pattern: Regex,
search_limit: usize,
playlist_load_limit: usize,
album_load_limit: usize,
artist_load_limit: usize,
proxy: Option<crate::config::HttpProxyConfig>,
}
const API_BASE: &str = "https://api.music.yandex.net";
impl YandexMusicSource {
pub fn new(
config: Option<crate::config::YandexMusicConfig>,
client: Arc<reqwest::Client>,
) -> Result<Self, String> {
let config = config.ok_or("Yandex Music configuration is missing")?;
let access_token = config
.access_token
.clone()
.ok_or("Yandex Music access token is missing")?;
Ok(Self {
client,
access_token,
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(),
playlist_pattern: Regex::new(r"(?i)^https?://music\.yandex\.(ru|com|kz|by)/users/(?P<user>[^/]+)/playlists/(?P<id>[0-9]+)/?").unwrap(),
playlist_uuid_pattern: Regex::new(r"(?i)^https?://music\.yandex\.(ru|com|kz|by)/playlists/(?P<uuid>[0-9a-z-]+)").unwrap(),
search_limit: config.search_limit,
playlist_load_limit: config.playlist_load_limit,
album_load_limit: config.album_load_limit,
artist_load_limit: config.artist_load_limit,
proxy: config.proxy,
})
}
async fn api_request(&self, endpoint: &str, params: Option<&[(&str, &str)]>) -> Option<Value> {
let mut url = format!("{}{}", API_BASE, endpoint);
if let Some(p) = params {
let mut first = true;
for (k, v) in p {
url.push_str(if first { "?" } else { "&" });
url.push_str(&format!("{}={}", k, urlencoding::encode(v)));
first = false;
}
}
debug!("Yandex Music API request: {}", url);
let builder = self.client.get(&url);
let resp = self.base_request(builder).send().await.ok()?;
let status = resp.status();
debug!("Yandex Music API response status: {} -> {}", url, status);
if !status.is_success() {
debug!("Yandex Music API request failed: {} -> {}", url, status);
return None;
}
let body: Value = resp.json().await.ok()?;
debug!("Yandex Music API response body: {}", body);
Some(body["result"].clone())
}
async fn search(&self, query: &str) -> LoadResult {
let data = match self
.api_request(
"/search",
Some(&[("text", query), ("type", "all"), ("page", "0")]),
)
.await
{
Some(d) => d,
None => return LoadResult::Empty {},
};
let tracks = self.parse_tracks(&data["tracks"]["results"]);
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Search(tracks)
}
async fn load_search_internal(
&self,
query: &str,
) -> Option<crate::protocol::tracks::SearchResult> {
let data = self
.api_request(
"/search",
Some(&[("text", query), ("type", "all"), ("page", "0")]),
)
.await?;
let tracks = self.parse_tracks(&data["tracks"]["results"]);
let mut albums = Vec::new();
if let Some(arr) = data["albums"]["results"].as_array() {
for item in arr.iter().take(self.search_limit) {
if let Some(playlist) = self.build_playlist_from_search(item, "album") {
albums.push(playlist);
}
}
}
let mut artists = Vec::new();
if let Some(arr) = data["artists"]["results"].as_array() {
for item in arr.iter().take(self.search_limit) {
if let Some(playlist) = self.build_playlist_from_search(item, "artist") {
artists.push(playlist);
}
}
}
let mut playlists = Vec::new();
if let Some(arr) = data["playlists"]["results"].as_array() {
for item in arr.iter().take(self.search_limit) {
if let Some(playlist) = self.build_playlist_from_search(item, "playlist") {
playlists.push(playlist);
}
}
}
Some(crate::protocol::tracks::SearchResult {
tracks,
albums,
artists,
playlists,
texts: Vec::new(),
plugin: json!({}),
})
}
fn build_playlist_from_search(&self, item: &Value, r#type: &str) -> Option<PlaylistData> {
if !item["available"].as_bool().unwrap_or(false) {
return None;
}
let name = match r#type {
"artist" => item["name"].as_str()?.to_string(),
_ => item["title"].as_str()?.to_string(),
};
Some(PlaylistData {
info: PlaylistInfo {
name,
selected_track: -1,
},
plugin_info: json!({ "type": r#type }),
tracks: Vec::new(),
})
}
async fn resolve_url(&self, url: &str) -> LoadResult {
if let Some(caps) = self.playlist_pattern.captures(url) {
let user = caps.name("user").map(|m| m.as_str()).unwrap();
let id = caps.name("id").map(|m| m.as_str()).unwrap();
return self.get_playlist(user, id).await;
}
if let Some(caps) = self.playlist_uuid_pattern.captures(url) {
let uuid = caps.name("uuid").map(|m| m.as_str()).unwrap();
return self.get_playlist_uuid(uuid).await;
}
if let Some(caps) = self.url_pattern.captures(url) {
let type1 = caps.get(2).map(|m| m.as_str()).unwrap();
let id1 = caps.name("id1").map(|m| m.as_str()).unwrap();
match type1 {
"track" => return self.get_track_internal(id1).await,
"album" => {
if let Some(id2) = caps.name("id2") {
return self.get_track_internal(id2.as_str()).await;
}
return self.get_album(id1).await;
}
"artist" => return self.get_artist(id1).await,
_ => {}
}
}
LoadResult::Empty {}
}
async fn get_track_internal(&self, id: &str) -> LoadResult {
let data = match self.api_request(&format!("/tracks/{}", id), None).await {
Some(d) => d,
None => return LoadResult::Empty {},
};
if let Some(track) = self.build_track(&data[0]) {
LoadResult::Track(track)
} else {
LoadResult::Empty {}
}
}
async fn get_album(&self, id: &str) -> LoadResult {
let page_size = (self.album_load_limit * 50).max(50).to_string();
let data = match self
.api_request(
&format!("/albums/{}/with-tracks", id),
Some(&[("page-size", &page_size)]),
)
.await
{
Some(d) => d,
None => return LoadResult::Empty {},
};
let mut tracks = Vec::new();
if let Some(volumes) = data["volumes"].as_array() {
for volume in volumes {
if let Some(arr) = volume.as_array() {
for item in arr {
if let Some(track) = self.build_track(item) {
tracks.push(track);
}
}
}
}
}
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: data["title"]
.as_str()
.unwrap_or("Yandex Music Album")
.to_string(),
selected_track: -1,
},
plugin_info: json!({ "type": "album" }),
tracks,
})
}
async fn get_artist(&self, id: &str) -> LoadResult {
let page_size = (self.artist_load_limit * 10).max(10).to_string();
let data = match self
.api_request(
&format!("/artists/{}/tracks", id),
Some(&[("page-size", &page_size)]),
)
.await
{
Some(d) => d,
None => return LoadResult::Empty {},
};
let tracks = self.parse_tracks(&data["tracks"]);
if tracks.is_empty() {
return LoadResult::Empty {};
}
let artist_data = match self.api_request(&format!("/artists/{}", id), None).await {
Some(d) => d,
None => return LoadResult::Empty {},
};
let name = artist_data["artist"]["name"].as_str().unwrap_or("Artist");
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: format!("{}'s Top Tracks", name),
selected_track: -1,
},
plugin_info: json!({ "type": "artist" }),
tracks,
})
}
async fn get_playlist(&self, user: &str, id: &str) -> LoadResult {
let page_size = (self.playlist_load_limit * 100).max(100).to_string();
let data = match self
.api_request(
&format!("/users/{}/playlists/{}", user, id),
Some(&[("page-size", &page_size), ("rich-tracks", "true")]),
)
.await
{
Some(d) => d,
None => return LoadResult::Empty {},
};
self.build_playlist_result(data)
}
async fn get_playlist_uuid(&self, uuid: &str) -> LoadResult {
let page_size = (self.playlist_load_limit * 100).max(100).to_string();
let data = match self
.api_request(
&format!("/playlist/{}", uuid),
Some(&[("page-size", &page_size), ("rich-tracks", "true")]),
)
.await
{
Some(d) => d,
None => return LoadResult::Empty {},
};
self.build_playlist_result(data)
}
async fn get_recommendations(&self, id: &str) -> LoadResult {
if !id.chars().all(|c| c.is_ascii_digit()) {
return LoadResult::Empty {};
}
let data = match self
.api_request(&format!("/tracks/{}/similar", id), None)
.await
{
Some(d) => d,
None => return LoadResult::Empty {},
};
let tracks = self.parse_tracks(&data["similarTracks"]);
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: "Yandex Music Recommendations".to_string(),
selected_track: -1,
},
plugin_info: json!({ "type": "recommendations" }),
tracks,
})
}
fn build_playlist_result(&self, data: Value) -> LoadResult {
let tracks = self.parse_tracks(&data["tracks"]);
if tracks.is_empty() {
return LoadResult::Empty {};
}
let title = if data["kind"].as_u64() == Some(3) {
let owner = data["owner"]["name"]
.as_str()
.or(data["owner"]["login"].as_str())
.unwrap_or("User");
format!("{}'s liked songs", owner)
} else {
data["title"]
.as_str()
.unwrap_or("Yandex Music Playlist")
.to_string()
};
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: title,
selected_track: -1,
},
plugin_info: json!({ "type": "playlist" }),
tracks,
})
}
fn parse_tracks(&self, data: &Value) -> Vec<Track> {
let mut tracks = Vec::new();
if let Some(arr) = data.as_array() {
for item in arr {
let track_json = if item.get("track").is_some() {
&item["track"]
} else {
item
};
if let Some(track) = self.build_track(track_json) {
tracks.push(track);
}
}
}
tracks
}
fn build_track(&self, data: &Value) -> Option<Track> {
if !data["available"].as_bool().unwrap_or(false) {
return None;
}
let id = data["id"]
.as_u64()
.map(|n| n.to_string())
.or(data["id"].as_str().map(|s| s.to_string()))?;
let title = data["title"].as_str()?;
let author = self.parse_artist(data);
let duration = data["durationMs"].as_u64().unwrap_or(0);
let uri = Some(format!("https://music.yandex.ru/track/{}", id));
let artwork_url = self.parse_cover_uri(data);
Some(Track::new(TrackInfo {
identifier: id,
is_seekable: true,
author,
length: duration,
is_stream: false,
position: 0,
title: title.to_string(),
uri,
artwork_url,
isrc: data["isrc"].as_str().map(|s| s.to_string()),
source_name: "yandexmusic".to_string(),
}))
}
fn parse_artist(&self, data: &Value) -> String {
if let Some(arr) = data["artists"].as_array() {
return arr
.iter()
.filter_map(|a| a["name"].as_str())
.collect::<Vec<_>>()
.join(", ");
}
"Unknown Artist".to_string()
}
fn parse_cover_uri(&self, data: &Value) -> Option<String> {
let uri = data["ogImage"]
.as_str()
.or(data["coverUri"].as_str())
.or(data["cover"]["uri"].as_str())?;
Some(format!("https://{}", uri.replace("%%", "400x400")))
}
fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder
.header(reqwest::header::USER_AGENT, "Yandex-Music-API")
.header("X-Yandex-Music-Client", "YandexMusicAndroid/24023621")
.header(
reqwest::header::AUTHORIZATION,
format!("OAuth {}", self.access_token),
)
}
}
#[async_trait]
impl SourcePlugin for YandexMusicSource {
fn name(&self) -> &str {
"yandexmusic"
}
fn can_handle(&self, identifier: &str) -> bool {
self.search_prefixes()
.iter()
.any(|p| identifier.starts_with(p))
|| self
.rec_prefixes()
.iter()
.any(|p| identifier.starts_with(p))
|| self.url_pattern.is_match(identifier)
|| self.playlist_pattern.is_match(identifier)
|| self.playlist_uuid_pattern.is_match(identifier)
}
fn search_prefixes(&self) -> Vec<&str> {
vec!["ymsearch:"]
}
fn rec_prefixes(&self) -> Vec<&str> {
vec!["ymrec:"]
}
async fn load(
&self,
identifier: &str,
_routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
) -> LoadResult {
if let Some(prefix) = self
.search_prefixes()
.into_iter()
.find(|p| identifier.starts_with(p))
{
return self.search(identifier.strip_prefix(prefix).unwrap()).await;
}
if let Some(prefix) = self
.rec_prefixes()
.into_iter()
.find(|p| identifier.starts_with(p))
{
return self
.get_recommendations(identifier.strip_prefix(prefix).unwrap())
.await;
}
self.resolve_url(identifier).await
}
async fn load_search(
&self,
query: &str,
_types: &[String],
_routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
) -> Option<crate::protocol::tracks::SearchResult> {
self.load_search_internal(query).await
}
async fn get_track(
&self,
identifier: &str,
routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
) -> Option<BoxedTrack> {
let track_id = if identifier.starts_with("http") {
if let Some(caps) = self.url_pattern.captures(identifier) {
caps.name("id2")
.or(caps.name("id1"))
.map(|m| m.as_str().to_string())?
} else {
return None;
}
} else {
identifier.to_string()
};
let stream_url = track::fetch_download_url(&self.client, &track_id).await;
if stream_url.is_none() {
debug!(
"Yandex Music: no stream URL for track {}, falling back to mirrors",
track_id
);
return None;
}
Some(Arc::new(track::YandexMusicTrack {
client: self.client.clone(),
track_id,
local_addr: routeplanner.and_then(|rp| rp.get_address()),
proxy: self.proxy.clone(),
}))
}
fn get_proxy_config(&self) -> Option<crate::config::HttpProxyConfig> {
self.proxy.clone()
}
}