Skip to main content

lavende_core/sources/
vkmusic.rs

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", &params).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;