1pub mod api {
2 use serde_json::Value;
3 use std::sync::Arc;
4 use tokio::sync::RwLock;
5 use tracing::{debug, error};
6 const VK_API: &str = "https://api.vk.com/method";
7 const VK_VERSION: &str = "5.199";
8 const API_UA: &str = "KateMobileAndroid/56 lite-460 (Android 4.4.2; SDK 19; x86; unknown Android SDK built for x86; en)";
9 const WEB_UA: &str =
10 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0";
11 pub struct VkApiClient {
12 pub client: Arc<reqwest::Client>,
13 pub token: Arc<RwLock<Option<String>>>,
14 pub user_id: Arc<RwLock<i64>>,
15 pub cookie: Option<String>,
16 }
17 impl VkApiClient {
18 pub fn new(
19 client: Arc<reqwest::Client>,
20 initial_token: Option<String>,
21 cookie: Option<String>,
22 ) -> Self {
23 Self {
24 client,
25 token: Arc::new(RwLock::new(initial_token)),
26 user_id: Arc::new(RwLock::new(0)),
27 cookie,
28 }
29 }
30 pub async fn call(&self, method: &str, params: &[(&'static str, String)]) -> Option<Value> {
31 let token = self.acquire_token().await?;
32 let body = self.do_request(method, &token, params).await?;
33 if let Some(code) = body["error"]["error_code"].as_i64() {
34 if code == 5 && self.cookie.is_some() {
35 *self.token.write().await = None;
36 let fresh = self.refresh_token().await?;
37 return self
38 .do_request(method, &fresh, params)
39 .await
40 .map(|b| b["response"].clone());
41 }
42 error!(
43 "VK API {}: {} — {}",
44 method,
45 code,
46 body["error"]["error_msg"].as_str().unwrap_or("")
47 );
48 return None;
49 }
50 Some(body["response"].clone())
51 }
52 async fn do_request(
53 &self,
54 method: &str,
55 token: &str,
56 params: &[(&'static str, String)],
57 ) -> Option<Value> {
58 let mut url = format!(
59 "{}/{}?access_token={}&v={}",
60 VK_API, method, token, VK_VERSION
61 );
62 for (k, v) in params {
63 url.push('&');
64 url.push_str(k);
65 url.push('=');
66 url.push_str(&urlencoding::encode(v));
67 }
68 let resp = self
69 .client
70 .get(&url)
71 .header("User-Agent", API_UA)
72 .send()
73 .await
74 .ok()?;
75 if !resp.status().is_success() {
76 debug!("VK API {} -> HTTP {}", method, resp.status());
77 return None;
78 }
79 resp.json().await.ok()
80 }
81 async fn acquire_token(&self) -> Option<String> {
82 let guard = self.token.read().await;
83 if let Some(t) = guard.as_ref() {
84 return Some(t.clone());
85 }
86 drop(guard);
87 self.refresh_token().await
88 }
89 pub async fn refresh_token(&self) -> Option<String> {
90 let cookie = self.cookie.as_deref()?;
91 debug!("VK Music: refreshing token");
92 let resp = self
93 .client
94 .post("https://login.vk.ru/?act=web_token")
95 .header("User-Agent", WEB_UA)
96 .header("Referer", "https://vk.ru/")
97 .header("Origin", "https://vk.ru")
98 .header("Cookie", cookie)
99 .header("Content-Type", "application/x-www-form-urlencoded")
100 .body("version=1&app_id=6287487")
101 .send()
102 .await
103 .ok()?;
104 let body: Value = resp.json().await.ok()?;
105 if body.get("type").and_then(|t| t.as_str()) != Some("okay") {
106 error!(
107 "VK Music token refresh failed: {}",
108 body.get("error_info")
109 .and_then(|e| e.as_str())
110 .unwrap_or("unknown")
111 );
112 return None;
113 }
114 let data = &body["data"];
115 let token = data["access_token"].as_str()?.to_string();
116 let uid = data["user_id"].as_i64().unwrap_or(0);
117 *self.token.write().await = Some(token.clone());
118 *self.user_id.write().await = uid;
119 Some(token)
120 }
121 }
122}
123pub mod manager {
124 use super::{
125 api::VkApiClient,
126 track::VkMusicTrack,
127 utils::{extract_thumbnail, unmask_vk_url},
128 };
129 use crate::{
130 config::VkMusicConfig,
131 protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
132 sources::{SourcePlugin, playable_track::BoxedTrack},
133 };
134 use async_trait::async_trait;
135 use regex::Regex;
136 use serde_json::{Value, json};
137 use std::sync::Arc;
138 pub struct VkMusicSource {
139 api: VkApiClient,
140 proxy: Option<crate::config::HttpProxyConfig>,
141 search_limit: usize,
142 playlist_track_limit: usize,
143 artist_track_limit: usize,
144 rec_limit: usize,
145 track_re: Regex,
146 playlist_z_re: Regex,
147 playlist_path_re: Regex,
148 artist_re: Regex,
149 audios_re: Regex,
150 }
151 impl VkMusicSource {
152 pub fn new(
153 config: Option<VkMusicConfig>,
154 client: Arc<reqwest::Client>,
155 ) -> Result<Self, String> {
156 let cfg = config.ok_or("VK Music configuration is missing")?;
157 let api = VkApiClient::new(client, cfg.user_token.clone(), cfg.user_cookie.clone());
158 Ok(Self {
159 api,
160 proxy: cfg.proxy,
161 search_limit: cfg.search_limit,
162 playlist_track_limit: cfg.playlist_load_limit * 50,
163 artist_track_limit: cfg.artist_load_limit * 10,
164 rec_limit: cfg.recommendations_load_limit,
165 track_re: Regex::new(
166 r"(?i)https?://vk\.(?:com|ru)/audio(?P<owner>-?\d+)_(?P<id>\d+)(?:_(?P<hash>[a-z0-9]+))?",
167 ).unwrap(),
168 playlist_z_re: Regex::new(
169 r"(?i)https?://vk\.(?:com|ru)/audios-?\d+\?[^#]*z=audio_playlist(?P<owner>-?\d+)_(?P<id>\d+)(?:(?:%2F|_)(?P<hash>[a-z0-9]+))?",
170 ).unwrap(),
171 playlist_path_re: Regex::new(
172 r"(?i)https?://vk\.(?:com|ru)/music/(?:playlist|album)/(?P<owner>-?\d+)_(?P<id>\d+)(?:_(?P<hash>[a-z0-9]+))?",
173 ).unwrap(),
174 artist_re: Regex::new(
175 r"(?i)https?://vk\.(?:com|ru)/artist/(?P<slug>[^/?#\s]+)"
176 ).unwrap(),
177 audios_re: Regex::new(
178 r"(?i)^https?://vk\.(?:com|ru)/audios(?P<owner>-?\d+)\s*$"
179 ).unwrap(),
180 })
181 }
182 async fn search(&self, q: &str) -> LoadResult {
183 let resp = self
184 .api
185 .call(
186 "audio.search",
187 &[
188 ("q", q.to_string()),
189 ("count", self.search_limit.to_string()),
190 ("sort", "2".to_string()),
191 ],
192 )
193 .await;
194 match resp.as_ref().and_then(|r| r["items"].as_array()) {
195 Some(items) if !items.is_empty() => {
196 let tracks: Vec<Track> =
197 items.iter().filter_map(|i| self.build_track(i)).collect();
198 if tracks.is_empty() {
199 LoadResult::Empty {}
200 } else {
201 LoadResult::Search(tracks)
202 }
203 }
204 _ => LoadResult::Empty {},
205 }
206 }
207 async fn recommendations(&self, target: &str) -> LoadResult {
208 let resp = self
209 .api
210 .call(
211 "audio.getRecommendations",
212 &[
213 ("target_audio", target.to_string()),
214 ("count", self.rec_limit.to_string()),
215 ],
216 )
217 .await;
218 let items = match resp.as_ref().and_then(|r| r["items"].as_array()) {
219 Some(arr) if !arr.is_empty() => arr.clone(),
220 _ => return LoadResult::Empty {},
221 };
222 let tracks: Vec<Track> = items.iter().filter_map(|i| self.build_track(i)).collect();
223 if tracks.is_empty() {
224 return LoadResult::Empty {};
225 }
226 LoadResult::Playlist(PlaylistData {
227 info: PlaylistInfo {
228 name: "VK Music Recommendations".to_string(),
229 selected_track: -1,
230 },
231 plugin_info: json!({ "type": "recommendations" }),
232 tracks,
233 })
234 }
235 async fn load_playlist(
236 &self,
237 owner: &str,
238 id: &str,
239 access_key: Option<&str>,
240 ) -> LoadResult {
241 let mut params = vec![
242 ("owner_id", owner.to_string()),
243 ("album_id", id.to_string()),
244 ("count", self.playlist_track_limit.to_string()),
245 ];
246 if let Some(key) = access_key {
247 params.push(("access_key", key.to_string()));
248 }
249 let resp = self.api.call("audio.get", ¶ms).await;
250 let items = match resp.as_ref().and_then(|r| r["items"].as_array()) {
251 Some(arr) if !arr.is_empty() => arr.clone(),
252 _ => return LoadResult::Empty {},
253 };
254 let tracks: Vec<Track> = items.iter().filter_map(|i| self.build_track(i)).collect();
255 if tracks.is_empty() {
256 return LoadResult::Empty {};
257 }
258 let mut meta_params = vec![
259 ("owner_id", owner.to_string()),
260 ("playlist_id", id.to_string()),
261 ];
262 if let Some(key) = access_key {
263 meta_params.push(("access_key", key.to_string()));
264 }
265 let meta = self.api.call("audio.getPlaylistById", &meta_params).await;
266 let title = meta
267 .as_ref()
268 .and_then(|m| m["title"].as_str())
269 .unwrap_or("VK Music Playlist")
270 .to_string();
271 let pl_type = if meta.as_ref().and_then(|m| m["type"].as_i64()) == Some(1) {
272 "album"
273 } else {
274 "playlist"
275 };
276 LoadResult::Playlist(PlaylistData {
277 info: PlaylistInfo {
278 name: title,
279 selected_track: -1,
280 },
281 plugin_info: json!({ "type": pl_type }),
282 tracks,
283 })
284 }
285 async fn load_track(&self, audio_id: &str) -> LoadResult {
286 let resp = self
287 .api
288 .call("audio.getById", &[("audios", audio_id.to_string())])
289 .await;
290 match resp
291 .as_ref()
292 .and_then(|r| r.as_array())
293 .and_then(|a| a.first())
294 {
295 Some(item) => match self.build_track(item) {
296 Some(t) => LoadResult::Track(t),
297 None => LoadResult::Empty {},
298 },
299 None => LoadResult::Empty {},
300 }
301 }
302 async fn load_artist(&self, slug: &str) -> LoadResult {
303 let artist = self
304 .api
305 .call("audio.getArtistById", &[("artist_id", slug.to_string())])
306 .await;
307 let artist_id = artist.as_ref().and_then(|r| {
308 r["id"]
309 .as_str()
310 .map(String::from)
311 .or_else(|| r["id"].as_i64().map(|n| n.to_string()))
312 });
313 let name = artist
314 .as_ref()
315 .and_then(|r| r["name"].as_str())
316 .unwrap_or(slug)
317 .to_string();
318 let resolved_id = artist_id.as_deref().unwrap_or(slug);
319 let resp = self
320 .api
321 .call(
322 "audio.getAudiosByArtist",
323 &[
324 ("artist_id", resolved_id.to_string()),
325 ("count", self.artist_track_limit.to_string()),
326 ],
327 )
328 .await;
329 let items = match resp.as_ref().and_then(|r| r["items"].as_array()) {
330 Some(arr) if !arr.is_empty() => arr.clone(),
331 _ => return LoadResult::Empty {},
332 };
333 let tracks: Vec<Track> = items.iter().filter_map(|i| self.build_track(i)).collect();
334 if tracks.is_empty() {
335 return LoadResult::Empty {};
336 }
337 LoadResult::Playlist(PlaylistData {
338 info: PlaylistInfo {
339 name: format!("{}'s Top Tracks", name),
340 selected_track: -1,
341 },
342 plugin_info: json!({ "type": "artist" }),
343 tracks,
344 })
345 }
346 async fn load_user_wall(&self, owner_id: &str) -> LoadResult {
347 let resp = self
348 .api
349 .call(
350 "audio.get",
351 &[
352 ("owner_id", owner_id.to_string()),
353 ("count", self.playlist_track_limit.to_string()),
354 ],
355 )
356 .await;
357 let items = match resp.as_ref().and_then(|r| r["items"].as_array()) {
358 Some(arr) if !arr.is_empty() => arr.clone(),
359 _ => return LoadResult::Empty {},
360 };
361 let tracks: Vec<Track> = items.iter().filter_map(|i| self.build_track(i)).collect();
362 if tracks.is_empty() {
363 return LoadResult::Empty {};
364 }
365 LoadResult::Playlist(PlaylistData {
366 info: PlaylistInfo {
367 name: "VK Music".to_string(),
368 selected_track: -1,
369 },
370 plugin_info: json!({ "type": "playlist" }),
371 tracks,
372 })
373 }
374 pub async fn resolve_stream_url(
375 &self,
376 audio_id: &str,
377 access_key: Option<&str>,
378 ) -> Option<String> {
379 let audios_param = match access_key {
380 Some(k) => format!("{}_{}", audio_id, k),
381 None => audio_id.to_string(),
382 };
383 let resp = self
384 .api
385 .call("audio.getById", &[("audios", audios_param)])
386 .await;
387 let raw_url = resp
388 .as_ref()
389 .and_then(|r| r.as_array())
390 .and_then(|arr| arr.first())
391 .and_then(|item| item["url"].as_str())
392 .filter(|u| !u.is_empty())
393 .map(String::from)?;
394 let uid = *self.api.user_id.read().await;
395 Some(unmask_vk_url(&raw_url, uid))
396 }
397 fn build_track(&self, item: &Value) -> Option<Track> {
398 let owner_id = item["owner_id"].as_i64()?;
399 let track_id = item["id"].as_i64()?;
400 let audio_id = format!("{}_{}", owner_id, track_id);
401 Some(Track::new(TrackInfo {
402 identifier: audio_id.clone(),
403 is_seekable: true,
404 author: item["artist"]
405 .as_str()
406 .unwrap_or("Unknown Artist")
407 .to_string(),
408 length: item["duration"].as_u64().unwrap_or(0) * 1000,
409 is_stream: false,
410 position: 0,
411 title: item["title"].as_str().unwrap_or("Unknown").to_string(),
412 uri: Some(format!("https://vk.com/audio{}", audio_id)),
413 artwork_url: extract_thumbnail(item),
414 isrc: None,
415 source_name: "vkmusic".to_string(),
416 }))
417 }
418 fn build_playlist_entry(&self, item: &Value, kind: &str) -> Option<PlaylistData> {
419 let title = item["title"].as_str()?;
420 let owner_id = item["owner_id"].as_i64()?;
421 let id = item["id"].as_i64()?;
422 let access_key = item["access_key"].as_str();
423 let uri = match access_key {
424 Some(key) => format!("https://vk.com/music/{}/{}_{}_{}", kind, owner_id, id, key),
425 None => format!("https://vk.com/music/{}/{}_{}", kind, owner_id, id),
426 };
427 Some(PlaylistData {
428 info: PlaylistInfo {
429 name: title.to_string(),
430 selected_track: -1,
431 },
432 plugin_info: json!({ "type": kind, "uri": uri }),
433 tracks: Vec::new(),
434 })
435 }
436 fn build_artist_entry(&self, item: &Value) -> Option<PlaylistData> {
437 let name = item["name"].as_str()?;
438 let domain = item["domain"].as_str().unwrap_or(name);
439 let artwork = item["photo"]
440 .as_array()
441 .and_then(|photos| {
442 photos
443 .iter()
444 .max_by_key(|p| p["width"].as_u64().unwrap_or(0))
445 })
446 .and_then(|p| p["url"].as_str())
447 .map(String::from);
448 Some(PlaylistData {
449 info: PlaylistInfo {
450 name: format!("{}'s Top Tracks", name),
451 selected_track: -1,
452 },
453 plugin_info: json!({ "type": "artist", "artworkUrl": artwork, "uri": format!("https://vk.com/artist/{}", domain) }),
454 tracks: Vec::new(),
455 })
456 }
457 }
458 #[async_trait]
459 impl SourcePlugin for VkMusicSource {
460 fn name(&self) -> &str {
461 "vkmusic"
462 }
463 fn can_handle(&self, id: &str) -> bool {
464 self.search_prefixes().iter().any(|p| id.starts_with(p))
465 || self.rec_prefixes().iter().any(|p| id.starts_with(p))
466 || self.track_re.is_match(id)
467 || self.playlist_z_re.is_match(id)
468 || self.playlist_path_re.is_match(id)
469 || self.artist_re.is_match(id)
470 || self.audios_re.is_match(id)
471 }
472 fn search_prefixes(&self) -> Vec<&str> {
473 vec!["vksearch:"]
474 }
475 fn rec_prefixes(&self) -> Vec<&str> {
476 vec!["vkrec:"]
477 }
478 async fn load(
479 &self,
480 identifier: &str,
481 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
482 ) -> LoadResult {
483 if let Some(prefix) = self
484 .search_prefixes()
485 .into_iter()
486 .find(|p| identifier.starts_with(p))
487 {
488 let query = identifier.strip_prefix(prefix).unwrap_or(identifier);
489 return self.search(query).await;
490 }
491 if let Some(prefix) = self
492 .rec_prefixes()
493 .into_iter()
494 .find(|p| identifier.starts_with(p))
495 {
496 let query = identifier.strip_prefix(prefix).unwrap_or(identifier);
497 return self.recommendations(query).await;
498 }
499 if let Some(caps) = self.playlist_z_re.captures(identifier) {
500 let owner = caps.name("owner").map(|m| m.as_str()).unwrap_or("");
501 let id = caps.name("id").map(|m| m.as_str()).unwrap_or("");
502 return self
503 .load_playlist(owner, id, caps.name("hash").map(|m| m.as_str()))
504 .await;
505 }
506 if let Some(caps) = self.playlist_path_re.captures(identifier) {
507 let owner = caps.name("owner").map(|m| m.as_str()).unwrap_or("");
508 let id = caps.name("id").map(|m| m.as_str()).unwrap_or("");
509 return self
510 .load_playlist(owner, id, caps.name("hash").map(|m| m.as_str()))
511 .await;
512 }
513 if let Some(caps) = self.track_re.captures(identifier) {
514 let owner = caps.name("owner").map(|m| m.as_str()).unwrap_or("");
515 let id = caps.name("id").map(|m| m.as_str()).unwrap_or("");
516 return self.load_track(&format!("{}_{}", owner, id)).await;
517 }
518 if let Some(caps) = self.artist_re.captures(identifier) {
519 return self
520 .load_artist(caps.name("slug").map(|m| m.as_str()).unwrap_or(""))
521 .await;
522 }
523 if let Some(caps) = self.audios_re.captures(identifier) {
524 return self
525 .load_user_wall(caps.name("owner").map(|m| m.as_str()).unwrap_or(""))
526 .await;
527 }
528 LoadResult::Empty {}
529 }
530 async fn load_search(
531 &self,
532 query: &str,
533 _types: &[String],
534 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
535 ) -> Option<crate::protocol::tracks::SearchResult> {
536 let q = self
537 .search_prefixes()
538 .into_iter()
539 .find_map(|p| query.strip_prefix(p))
540 .unwrap_or(query);
541 let count = self.search_limit.to_string();
542 let track_params = [
543 ("q", q.to_string()),
544 ("count", count.clone()),
545 ("sort", "2".to_string()),
546 ];
547 let album_params = [("q", q.to_string()), ("count", "20".to_string())];
548 let artist_params = [("q", q.to_string()), ("count", "20".to_string())];
549 let playlist_params = [("q", q.to_string()), ("count", "20".to_string())];
550 let (tracks_resp, albums_resp, artists_resp, playlists_resp) = tokio::join!(
551 self.api.call("audio.search", &track_params),
552 self.api.call("audio.searchAlbums", &album_params),
553 self.api.call("audio.searchArtists", &artist_params),
554 self.api.call("audio.searchPlaylists", &playlist_params),
555 );
556 let tracks: Vec<Track> = tracks_resp
557 .as_ref()
558 .and_then(|r| r["items"].as_array())
559 .map(|arr| arr.iter().filter_map(|i| self.build_track(i)).collect())
560 .unwrap_or_default();
561 let albums: Vec<PlaylistData> = albums_resp
562 .as_ref()
563 .and_then(|r| r["items"].as_array())
564 .map(|arr| {
565 arr.iter()
566 .filter_map(|i| self.build_playlist_entry(i, "album"))
567 .collect()
568 })
569 .unwrap_or_default();
570 let artists: Vec<PlaylistData> = artists_resp
571 .as_ref()
572 .and_then(|r| r["items"].as_array())
573 .map(|arr| {
574 arr.iter()
575 .filter_map(|i| self.build_artist_entry(i))
576 .collect()
577 })
578 .unwrap_or_default();
579 let playlists: Vec<PlaylistData> = playlists_resp
580 .as_ref()
581 .and_then(|r| r["items"].as_array())
582 .map(|arr| {
583 arr.iter()
584 .filter_map(|i| self.build_playlist_entry(i, "playlist"))
585 .collect()
586 })
587 .unwrap_or_default();
588 Some(crate::protocol::tracks::SearchResult {
589 tracks,
590 albums,
591 artists,
592 playlists,
593 texts: Vec::new(),
594 plugin: json!({}),
595 })
596 }
597 async fn get_track(
598 &self,
599 identifier: &str,
600 routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
601 ) -> Option<BoxedTrack> {
602 let audio_id = if let Some(caps) = self.track_re.captures(identifier) {
603 let owner = caps.name("owner")?.as_str();
604 let id = caps.name("id")?.as_str();
605 format!("{}_{}", owner, id)
606 } else {
607 identifier.to_string()
608 };
609 let stream_url = self.resolve_stream_url(&audio_id, None).await?;
610 Some(Arc::new(VkMusicTrack {
611 stream_url,
612 proxy: self.proxy.clone(),
613 local_addr: routeplanner.and_then(|rp| rp.get_address()),
614 }))
615 }
616 fn get_proxy_config(&self) -> Option<crate::config::HttpProxyConfig> {
617 self.proxy.clone()
618 }
619 }
620}
621pub mod track {
622 use crate::{
623 config::HttpProxyConfig,
624 sources::{
625 http::HttpTrack,
626 playable_track::{PlayableTrack, ResolvedTrack},
627 },
628 };
629 use async_trait::async_trait;
630 use std::net::IpAddr;
631 pub struct VkMusicTrack {
632 pub stream_url: String,
633 pub local_addr: Option<IpAddr>,
634 pub proxy: Option<HttpProxyConfig>,
635 }
636 #[async_trait]
637 impl PlayableTrack for VkMusicTrack {
638 async fn resolve(&self) -> Result<ResolvedTrack, String> {
639 HttpTrack {
640 url: self.stream_url.clone(),
641 local_addr: self.local_addr,
642 proxy: self.proxy.clone(),
643 }
644 .resolve()
645 .await
646 }
647 }
648}
649pub mod utils {
650 use serde_json::Value;
651 const VK_B64_TABLE: &str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN0PQRSTUVWXYZO123456789+/=";
652 pub fn unmask_vk_url(url: &str, user_id: i64) -> String {
653 if !url.contains("audio_api_unavailable") {
654 return url.to_string();
655 }
656 let decode = || -> Option<String> {
657 let extra = url.split("?extra=").nth(1)?;
658 let (enc_url, enc_ops) = extra.split_once('#')?;
659 let mut chars: Vec<char> = vk_b64_decode(enc_url).chars().collect();
660 let ops_str = vk_b64_decode(enc_ops);
661 let ops: Vec<&str> = ops_str.split('\x0b').collect();
662 if ops.len() < 2 {
663 return None;
664 }
665 let seed: i64 = ops[1].parse().ok()?;
666 let mut index = seed ^ user_id;
667 let len = chars.len();
668 let mut swap_indices = vec![0usize; len];
669 for n in (0..len).rev() {
670 index = ((len as i64 * (n as i64 + 1)) ^ (index + n as i64)) % len as i64;
671 swap_indices[n] = index.unsigned_abs() as usize;
672 }
673 for n in 1..len {
674 chars.swap(n, swap_indices[len - 1 - n]);
675 }
676 Some(chars.iter().collect())
677 };
678 decode().unwrap_or_else(|| url.to_string())
679 }
680 fn vk_b64_decode(encoded: &str) -> String {
681 let table: Vec<char> = VK_B64_TABLE.chars().collect();
682 let mut out = String::new();
683 let mut acc = 0u32;
684 let mut bits = 0u32;
685 for ch in encoded.chars() {
686 let Some(pos) = table.iter().position(|&c| c == ch) else {
687 continue;
688 };
689 acc = (acc << 6) | pos as u32;
690 bits += 6;
691 if bits >= 8 {
692 bits -= 8;
693 out.push(char::from_u32((acc >> bits) & 0xFF).unwrap_or('\0'));
694 }
695 }
696 out
697 }
698 pub fn extract_thumbnail(item: &Value) -> Option<String> {
699 let thumb = &item["album"]["thumb"];
700 ["photo_1200", "photo_600", "photo_300"]
701 .iter()
702 .find_map(|&key| {
703 thumb[key]
704 .as_str()
705 .filter(|u| !u.is_empty())
706 .map(String::from)
707 })
708 }
709}
710pub use manager::VkMusicSource;