pub mod api {
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, error};
const VK_API: &str = "https://api.vk.com/method";
const VK_VERSION: &str = "5.199";
const API_UA: &str = "KateMobileAndroid/56 lite-460 (Android 4.4.2; SDK 19; x86; unknown Android SDK built for x86; en)";
const WEB_UA: &str =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0";
pub struct VkApiClient {
pub client: Arc<reqwest::Client>,
pub token: Arc<RwLock<Option<String>>>,
pub user_id: Arc<RwLock<i64>>,
pub cookie: Option<String>,
}
impl VkApiClient {
pub fn new(
client: Arc<reqwest::Client>,
initial_token: Option<String>,
cookie: Option<String>,
) -> Self {
Self {
client,
token: Arc::new(RwLock::new(initial_token)),
user_id: Arc::new(RwLock::new(0)),
cookie,
}
}
pub async fn call(&self, method: &str, params: &[(&'static str, String)]) -> Option<Value> {
let token = self.acquire_token().await?;
let body = self.do_request(method, &token, params).await?;
if let Some(code) = body["error"]["error_code"].as_i64() {
if code == 5 && self.cookie.is_some() {
*self.token.write().await = None;
let fresh = self.refresh_token().await?;
return self
.do_request(method, &fresh, params)
.await
.map(|b| b["response"].clone());
}
error!(
"VK API {}: {} — {}",
method,
code,
body["error"]["error_msg"].as_str().unwrap_or("")
);
return None;
}
Some(body["response"].clone())
}
async fn do_request(
&self,
method: &str,
token: &str,
params: &[(&'static str, String)],
) -> Option<Value> {
let mut url = format!(
"{}/{}?access_token={}&v={}",
VK_API, method, token, VK_VERSION
);
for (k, v) in params {
url.push('&');
url.push_str(k);
url.push('=');
url.push_str(&urlencoding::encode(v));
}
let resp = self
.client
.get(&url)
.header("User-Agent", API_UA)
.send()
.await
.ok()?;
if !resp.status().is_success() {
debug!("VK API {} -> HTTP {}", method, resp.status());
return None;
}
resp.json().await.ok()
}
async fn acquire_token(&self) -> Option<String> {
let guard = self.token.read().await;
if let Some(t) = guard.as_ref() {
return Some(t.clone());
}
drop(guard);
self.refresh_token().await
}
pub async fn refresh_token(&self) -> Option<String> {
let cookie = self.cookie.as_deref()?;
debug!("VK Music: refreshing token");
let resp = self
.client
.post("https://login.vk.ru/?act=web_token")
.header("User-Agent", WEB_UA)
.header("Referer", "https://vk.ru/")
.header("Origin", "https://vk.ru")
.header("Cookie", cookie)
.header("Content-Type", "application/x-www-form-urlencoded")
.body("version=1&app_id=6287487")
.send()
.await
.ok()?;
let body: Value = resp.json().await.ok()?;
if body.get("type").and_then(|t| t.as_str()) != Some("okay") {
error!(
"VK Music token refresh failed: {}",
body.get("error_info")
.and_then(|e| e.as_str())
.unwrap_or("unknown")
);
return None;
}
let data = &body["data"];
let token = data["access_token"].as_str()?.to_string();
let uid = data["user_id"].as_i64().unwrap_or(0);
*self.token.write().await = Some(token.clone());
*self.user_id.write().await = uid;
Some(token)
}
}
}
pub mod manager {
use super::{
api::VkApiClient,
track::VkMusicTrack,
utils::{extract_thumbnail, unmask_vk_url},
};
use crate::{
config::VkMusicConfig,
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;
pub struct VkMusicSource {
api: VkApiClient,
proxy: Option<crate::config::HttpProxyConfig>,
search_limit: usize,
playlist_track_limit: usize,
artist_track_limit: usize,
rec_limit: usize,
track_re: Regex,
playlist_z_re: Regex,
playlist_path_re: Regex,
artist_re: Regex,
audios_re: Regex,
}
impl VkMusicSource {
pub fn new(
config: Option<VkMusicConfig>,
client: Arc<reqwest::Client>,
) -> Result<Self, String> {
let cfg = config.ok_or("VK Music configuration is missing")?;
let api = VkApiClient::new(client, cfg.user_token.clone(), cfg.user_cookie.clone());
Ok(Self {
api,
proxy: cfg.proxy,
search_limit: cfg.search_limit,
playlist_track_limit: cfg.playlist_load_limit * 50,
artist_track_limit: cfg.artist_load_limit * 10,
rec_limit: cfg.recommendations_load_limit,
track_re: Regex::new(
r"(?i)https?://vk\.(?:com|ru)/audio(?P<owner>-?\d+)_(?P<id>\d+)(?:_(?P<hash>[a-z0-9]+))?",
).unwrap(),
playlist_z_re: Regex::new(
r"(?i)https?://vk\.(?:com|ru)/audios-?\d+\?[^#]*z=audio_playlist(?P<owner>-?\d+)_(?P<id>\d+)(?:(?:%2F|_)(?P<hash>[a-z0-9]+))?",
).unwrap(),
playlist_path_re: Regex::new(
r"(?i)https?://vk\.(?:com|ru)/music/(?:playlist|album)/(?P<owner>-?\d+)_(?P<id>\d+)(?:_(?P<hash>[a-z0-9]+))?",
).unwrap(),
artist_re: Regex::new(
r"(?i)https?://vk\.(?:com|ru)/artist/(?P<slug>[^/?#\s]+)"
).unwrap(),
audios_re: Regex::new(
r"(?i)^https?://vk\.(?:com|ru)/audios(?P<owner>-?\d+)\s*$"
).unwrap(),
})
}
async fn search(&self, q: &str) -> LoadResult {
let resp = self
.api
.call(
"audio.search",
&[
("q", q.to_string()),
("count", self.search_limit.to_string()),
("sort", "2".to_string()),
],
)
.await;
match resp.as_ref().and_then(|r| r["items"].as_array()) {
Some(items) if !items.is_empty() => {
let tracks: Vec<Track> =
items.iter().filter_map(|i| self.build_track(i)).collect();
if tracks.is_empty() {
LoadResult::Empty {}
} else {
LoadResult::Search(tracks)
}
}
_ => LoadResult::Empty {},
}
}
async fn recommendations(&self, target: &str) -> LoadResult {
let resp = self
.api
.call(
"audio.getRecommendations",
&[
("target_audio", target.to_string()),
("count", self.rec_limit.to_string()),
],
)
.await;
let items = match resp.as_ref().and_then(|r| r["items"].as_array()) {
Some(arr) if !arr.is_empty() => arr.clone(),
_ => return LoadResult::Empty {},
};
let tracks: Vec<Track> = items.iter().filter_map(|i| self.build_track(i)).collect();
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: "VK Music Recommendations".to_string(),
selected_track: -1,
},
plugin_info: json!({ "type": "recommendations" }),
tracks,
})
}
async fn load_playlist(
&self,
owner: &str,
id: &str,
access_key: Option<&str>,
) -> LoadResult {
let mut params = vec![
("owner_id", owner.to_string()),
("album_id", id.to_string()),
("count", self.playlist_track_limit.to_string()),
];
if let Some(key) = access_key {
params.push(("access_key", key.to_string()));
}
let resp = self.api.call("audio.get", ¶ms).await;
let items = match resp.as_ref().and_then(|r| r["items"].as_array()) {
Some(arr) if !arr.is_empty() => arr.clone(),
_ => return LoadResult::Empty {},
};
let tracks: Vec<Track> = items.iter().filter_map(|i| self.build_track(i)).collect();
if tracks.is_empty() {
return LoadResult::Empty {};
}
let mut meta_params = vec![
("owner_id", owner.to_string()),
("playlist_id", id.to_string()),
];
if let Some(key) = access_key {
meta_params.push(("access_key", key.to_string()));
}
let meta = self.api.call("audio.getPlaylistById", &meta_params).await;
let title = meta
.as_ref()
.and_then(|m| m["title"].as_str())
.unwrap_or("VK Music Playlist")
.to_string();
let pl_type = if meta.as_ref().and_then(|m| m["type"].as_i64()) == Some(1) {
"album"
} else {
"playlist"
};
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: title,
selected_track: -1,
},
plugin_info: json!({ "type": pl_type }),
tracks,
})
}
async fn load_track(&self, audio_id: &str) -> LoadResult {
let resp = self
.api
.call("audio.getById", &[("audios", audio_id.to_string())])
.await;
match resp
.as_ref()
.and_then(|r| r.as_array())
.and_then(|a| a.first())
{
Some(item) => match self.build_track(item) {
Some(t) => LoadResult::Track(t),
None => LoadResult::Empty {},
},
None => LoadResult::Empty {},
}
}
async fn load_artist(&self, slug: &str) -> LoadResult {
let artist = self
.api
.call("audio.getArtistById", &[("artist_id", slug.to_string())])
.await;
let artist_id = artist.as_ref().and_then(|r| {
r["id"]
.as_str()
.map(String::from)
.or_else(|| r["id"].as_i64().map(|n| n.to_string()))
});
let name = artist
.as_ref()
.and_then(|r| r["name"].as_str())
.unwrap_or(slug)
.to_string();
let resolved_id = artist_id.as_deref().unwrap_or(slug);
let resp = self
.api
.call(
"audio.getAudiosByArtist",
&[
("artist_id", resolved_id.to_string()),
("count", self.artist_track_limit.to_string()),
],
)
.await;
let items = match resp.as_ref().and_then(|r| r["items"].as_array()) {
Some(arr) if !arr.is_empty() => arr.clone(),
_ => return LoadResult::Empty {},
};
let tracks: Vec<Track> = items.iter().filter_map(|i| self.build_track(i)).collect();
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: format!("{}'s Top Tracks", name),
selected_track: -1,
},
plugin_info: json!({ "type": "artist" }),
tracks,
})
}
async fn load_user_wall(&self, owner_id: &str) -> LoadResult {
let resp = self
.api
.call(
"audio.get",
&[
("owner_id", owner_id.to_string()),
("count", self.playlist_track_limit.to_string()),
],
)
.await;
let items = match resp.as_ref().and_then(|r| r["items"].as_array()) {
Some(arr) if !arr.is_empty() => arr.clone(),
_ => return LoadResult::Empty {},
};
let tracks: Vec<Track> = items.iter().filter_map(|i| self.build_track(i)).collect();
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: "VK Music".to_string(),
selected_track: -1,
},
plugin_info: json!({ "type": "playlist" }),
tracks,
})
}
pub async fn resolve_stream_url(
&self,
audio_id: &str,
access_key: Option<&str>,
) -> Option<String> {
let audios_param = match access_key {
Some(k) => format!("{}_{}", audio_id, k),
None => audio_id.to_string(),
};
let resp = self
.api
.call("audio.getById", &[("audios", audios_param)])
.await;
let raw_url = resp
.as_ref()
.and_then(|r| r.as_array())
.and_then(|arr| arr.first())
.and_then(|item| item["url"].as_str())
.filter(|u| !u.is_empty())
.map(String::from)?;
let uid = *self.api.user_id.read().await;
Some(unmask_vk_url(&raw_url, uid))
}
fn build_track(&self, item: &Value) -> Option<Track> {
let owner_id = item["owner_id"].as_i64()?;
let track_id = item["id"].as_i64()?;
let audio_id = format!("{}_{}", owner_id, track_id);
Some(Track::new(TrackInfo {
identifier: audio_id.clone(),
is_seekable: true,
author: item["artist"]
.as_str()
.unwrap_or("Unknown Artist")
.to_string(),
length: item["duration"].as_u64().unwrap_or(0) * 1000,
is_stream: false,
position: 0,
title: item["title"].as_str().unwrap_or("Unknown").to_string(),
uri: Some(format!("https://vk.com/audio{}", audio_id)),
artwork_url: extract_thumbnail(item),
isrc: None,
source_name: "vkmusic".to_string(),
}))
}
fn build_playlist_entry(&self, item: &Value, kind: &str) -> Option<PlaylistData> {
let title = item["title"].as_str()?;
let owner_id = item["owner_id"].as_i64()?;
let id = item["id"].as_i64()?;
let access_key = item["access_key"].as_str();
let uri = match access_key {
Some(key) => format!("https://vk.com/music/{}/{}_{}_{}", kind, owner_id, id, key),
None => format!("https://vk.com/music/{}/{}_{}", kind, owner_id, id),
};
Some(PlaylistData {
info: PlaylistInfo {
name: title.to_string(),
selected_track: -1,
},
plugin_info: json!({ "type": kind, "uri": uri }),
tracks: Vec::new(),
})
}
fn build_artist_entry(&self, item: &Value) -> Option<PlaylistData> {
let name = item["name"].as_str()?;
let domain = item["domain"].as_str().unwrap_or(name);
let artwork = item["photo"]
.as_array()
.and_then(|photos| {
photos
.iter()
.max_by_key(|p| p["width"].as_u64().unwrap_or(0))
})
.and_then(|p| p["url"].as_str())
.map(String::from);
Some(PlaylistData {
info: PlaylistInfo {
name: format!("{}'s Top Tracks", name),
selected_track: -1,
},
plugin_info: json!({ "type": "artist", "artworkUrl": artwork, "uri": format!("https://vk.com/artist/{}", domain) }),
tracks: Vec::new(),
})
}
}
#[async_trait]
impl SourcePlugin for VkMusicSource {
fn name(&self) -> &str {
"vkmusic"
}
fn can_handle(&self, id: &str) -> bool {
self.search_prefixes().iter().any(|p| id.starts_with(p))
|| self.rec_prefixes().iter().any(|p| id.starts_with(p))
|| self.track_re.is_match(id)
|| self.playlist_z_re.is_match(id)
|| self.playlist_path_re.is_match(id)
|| self.artist_re.is_match(id)
|| self.audios_re.is_match(id)
}
fn search_prefixes(&self) -> Vec<&str> {
vec!["vksearch:"]
}
fn rec_prefixes(&self) -> Vec<&str> {
vec!["vkrec:"]
}
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))
{
let query = identifier.strip_prefix(prefix).unwrap_or(identifier);
return self.search(query).await;
}
if let Some(prefix) = self
.rec_prefixes()
.into_iter()
.find(|p| identifier.starts_with(p))
{
let query = identifier.strip_prefix(prefix).unwrap_or(identifier);
return self.recommendations(query).await;
}
if let Some(caps) = self.playlist_z_re.captures(identifier) {
let owner = caps.name("owner").map(|m| m.as_str()).unwrap_or("");
let id = caps.name("id").map(|m| m.as_str()).unwrap_or("");
return self
.load_playlist(owner, id, caps.name("hash").map(|m| m.as_str()))
.await;
}
if let Some(caps) = self.playlist_path_re.captures(identifier) {
let owner = caps.name("owner").map(|m| m.as_str()).unwrap_or("");
let id = caps.name("id").map(|m| m.as_str()).unwrap_or("");
return self
.load_playlist(owner, id, caps.name("hash").map(|m| m.as_str()))
.await;
}
if let Some(caps) = self.track_re.captures(identifier) {
let owner = caps.name("owner").map(|m| m.as_str()).unwrap_or("");
let id = caps.name("id").map(|m| m.as_str()).unwrap_or("");
return self.load_track(&format!("{}_{}", owner, id)).await;
}
if let Some(caps) = self.artist_re.captures(identifier) {
return self
.load_artist(caps.name("slug").map(|m| m.as_str()).unwrap_or(""))
.await;
}
if let Some(caps) = self.audios_re.captures(identifier) {
return self
.load_user_wall(caps.name("owner").map(|m| m.as_str()).unwrap_or(""))
.await;
}
LoadResult::Empty {}
}
async fn load_search(
&self,
query: &str,
_types: &[String],
_routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
) -> Option<crate::protocol::tracks::SearchResult> {
let q = self
.search_prefixes()
.into_iter()
.find_map(|p| query.strip_prefix(p))
.unwrap_or(query);
let count = self.search_limit.to_string();
let track_params = [
("q", q.to_string()),
("count", count.clone()),
("sort", "2".to_string()),
];
let album_params = [("q", q.to_string()), ("count", "20".to_string())];
let artist_params = [("q", q.to_string()), ("count", "20".to_string())];
let playlist_params = [("q", q.to_string()), ("count", "20".to_string())];
let (tracks_resp, albums_resp, artists_resp, playlists_resp) = tokio::join!(
self.api.call("audio.search", &track_params),
self.api.call("audio.searchAlbums", &album_params),
self.api.call("audio.searchArtists", &artist_params),
self.api.call("audio.searchPlaylists", &playlist_params),
);
let tracks: Vec<Track> = tracks_resp
.as_ref()
.and_then(|r| r["items"].as_array())
.map(|arr| arr.iter().filter_map(|i| self.build_track(i)).collect())
.unwrap_or_default();
let albums: Vec<PlaylistData> = albums_resp
.as_ref()
.and_then(|r| r["items"].as_array())
.map(|arr| {
arr.iter()
.filter_map(|i| self.build_playlist_entry(i, "album"))
.collect()
})
.unwrap_or_default();
let artists: Vec<PlaylistData> = artists_resp
.as_ref()
.and_then(|r| r["items"].as_array())
.map(|arr| {
arr.iter()
.filter_map(|i| self.build_artist_entry(i))
.collect()
})
.unwrap_or_default();
let playlists: Vec<PlaylistData> = playlists_resp
.as_ref()
.and_then(|r| r["items"].as_array())
.map(|arr| {
arr.iter()
.filter_map(|i| self.build_playlist_entry(i, "playlist"))
.collect()
})
.unwrap_or_default();
Some(crate::protocol::tracks::SearchResult {
tracks,
albums,
artists,
playlists,
texts: Vec::new(),
plugin: json!({}),
})
}
async fn get_track(
&self,
identifier: &str,
routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
) -> Option<BoxedTrack> {
let audio_id = if let Some(caps) = self.track_re.captures(identifier) {
let owner = caps.name("owner")?.as_str();
let id = caps.name("id")?.as_str();
format!("{}_{}", owner, id)
} else {
identifier.to_string()
};
let stream_url = self.resolve_stream_url(&audio_id, None).await?;
Some(Arc::new(VkMusicTrack {
stream_url,
proxy: self.proxy.clone(),
local_addr: routeplanner.and_then(|rp| rp.get_address()),
}))
}
fn get_proxy_config(&self) -> Option<crate::config::HttpProxyConfig> {
self.proxy.clone()
}
}
}
pub mod track {
use crate::{
config::HttpProxyConfig,
sources::{
http::HttpTrack,
playable_track::{PlayableTrack, ResolvedTrack},
},
};
use async_trait::async_trait;
use std::net::IpAddr;
pub struct VkMusicTrack {
pub stream_url: String,
pub local_addr: Option<IpAddr>,
pub proxy: Option<HttpProxyConfig>,
}
#[async_trait]
impl PlayableTrack for VkMusicTrack {
async fn resolve(&self) -> Result<ResolvedTrack, String> {
HttpTrack {
url: self.stream_url.clone(),
local_addr: self.local_addr,
proxy: self.proxy.clone(),
}
.resolve()
.await
}
}
}
pub mod utils {
use serde_json::Value;
const VK_B64_TABLE: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN0PQRSTUVWXYZO123456789+/=";
pub fn unmask_vk_url(url: &str, user_id: i64) -> String {
if !url.contains("audio_api_unavailable") {
return url.to_string();
}
let decode = || -> Option<String> {
let extra = url.split("?extra=").nth(1)?;
let (enc_url, enc_ops) = extra.split_once('#')?;
let mut chars: Vec<char> = vk_b64_decode(enc_url).chars().collect();
let ops_str = vk_b64_decode(enc_ops);
let ops: Vec<&str> = ops_str.split('\x0b').collect();
if ops.len() < 2 {
return None;
}
let seed: i64 = ops[1].parse().ok()?;
let mut index = seed ^ user_id;
let len = chars.len();
let mut swap_indices = vec![0usize; len];
for n in (0..len).rev() {
index = ((len as i64 * (n as i64 + 1)) ^ (index + n as i64)) % len as i64;
swap_indices[n] = index.unsigned_abs() as usize;
}
for n in 1..len {
chars.swap(n, swap_indices[len - 1 - n]);
}
Some(chars.iter().collect())
};
decode().unwrap_or_else(|| url.to_string())
}
fn vk_b64_decode(encoded: &str) -> String {
let table: Vec<char> = VK_B64_TABLE.chars().collect();
let mut out = String::new();
let mut acc = 0u32;
let mut bits = 0u32;
for ch in encoded.chars() {
let Some(pos) = table.iter().position(|&c| c == ch) else {
continue;
};
acc = (acc << 6) | pos as u32;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(char::from_u32((acc >> bits) & 0xFF).unwrap_or('\0'));
}
}
out
}
pub fn extract_thumbnail(item: &Value) -> Option<String> {
let thumb = &item["album"]["thumb"];
["photo_1200", "photo_600", "photo_300"]
.iter()
.find_map(|&key| {
thumb[key]
.as_str()
.filter(|u| !u.is_empty())
.map(String::from)
})
}
}
pub use manager::VkMusicSource;