Skip to main content

lavende_core/sources/
netease.rs

1pub mod api {
2    use aes::{
3        Aes128,
4        cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray},
5    };
6    use md5::{Digest, Md5};
7    use serde::{Deserialize, de::DeserializeOwned};
8    use serde_json::Value;
9    use tracing::{debug, error};
10    #[derive(Debug, Deserialize)]
11    pub struct NeteaseResponse<T> {
12        pub code: i64,
13        #[serde(flatten)]
14        pub data: T,
15    }
16    #[derive(Debug, Deserialize)]
17    pub struct NeteaseArtist {
18        pub id: i64,
19        pub name: String,
20    }
21    #[derive(Debug, Deserialize)]
22    pub struct NeteaseAlbum {
23        pub id: i64,
24        pub name: String,
25        #[serde(alias = "picUrl")]
26        pub pic_url: Option<String>,
27    }
28    #[derive(Debug, Deserialize)]
29    pub struct NeteaseSong {
30        pub id: i64,
31        pub name: String,
32        #[serde(alias = "ar", alias = "artists")]
33        pub artists: Vec<NeteaseArtist>,
34        #[serde(alias = "al", alias = "album")]
35        pub album: Option<NeteaseAlbum>,
36        #[serde(alias = "dt", alias = "duration")]
37        pub duration: u64,
38    }
39    #[derive(Debug, Deserialize)]
40    pub struct SearchResultData {
41        pub result: SearchResultInner,
42    }
43    #[derive(Debug, Deserialize)]
44    pub struct SearchResultInner {
45        #[serde(default)]
46        pub songs: Vec<NeteaseSong>,
47        #[serde(default)]
48        pub albums: Vec<NeteaseAlbum>,
49        #[serde(default)]
50        pub artists: Vec<NeteaseArtist>,
51        #[serde(default)]
52        pub playlists: Vec<NeteasePlaylist>,
53    }
54    #[derive(Debug, Deserialize)]
55    pub struct NeteasePlaylist {
56        pub id: i64,
57        pub name: String,
58        #[serde(alias = "coverImgUrl")]
59        pub cover_img_url: Option<String>,
60        pub creator: Option<NeteaseCreator>,
61        #[serde(alias = "trackCount")]
62        pub track_count: Option<u64>,
63    }
64    #[derive(Debug, Deserialize)]
65    pub struct NeteaseCreator {
66        pub nickname: String,
67    }
68    #[derive(Debug, Deserialize)]
69    pub struct SongDetailData {
70        pub songs: Vec<NeteaseSong>,
71    }
72    #[derive(Debug, Deserialize)]
73    pub struct TrackUrlData {
74        pub data: Vec<TrackUrlItem>,
75    }
76    #[derive(Debug, Deserialize)]
77    pub struct TrackUrlItem {
78        pub id: i64,
79        pub url: Option<String>,
80        pub br: i64,
81        pub code: i64,
82        #[serde(rename = "freeTrialInfo")]
83        pub free_trial_info: Option<serde_json::Value>,
84    }
85    #[derive(Debug, Deserialize)]
86    pub struct SimilarSongsData {
87        pub songs: Vec<NeteaseSong>,
88    }
89    #[derive(Debug, Deserialize)]
90    pub struct AlbumDetailData {
91        pub album: NeteaseAlbum,
92        pub songs: Vec<NeteaseSong>,
93    }
94    #[derive(Debug, Deserialize)]
95    pub struct PlaylistDetailData {
96        pub playlist: PlaylistInfo,
97    }
98    #[derive(Debug, Deserialize)]
99    pub struct PlaylistInfo {
100        pub id: i64,
101        pub name: String,
102        pub tracks: Vec<NeteaseSong>,
103    }
104    #[derive(Debug, Deserialize)]
105    pub struct ArtistDetailData {
106        pub artist: NeteaseArtist,
107        #[serde(rename = "hotSongs")]
108        pub hot_songs: Vec<NeteaseSong>,
109    }
110    const EAPI_KEY: &[u8] = b"e82ckenh8dichen8";
111    const EAPI_URLS: &[&str] = &[
112        "https://interface3.music.163.com/eapi",
113        "https://interface.music.163.com/eapi",
114    ];
115    pub fn aes_encrypt_ecb(data: &[u8], key: &[u8]) -> Vec<u8> {
116        let cipher = Aes128::new(GenericArray::from_slice(key));
117        let mut padded_data = pkcs7_pad(data, 16);
118        for chunk in padded_data.chunks_mut(16) {
119            let block = GenericArray::from_mut_slice(chunk);
120            cipher.encrypt_block(block);
121        }
122        padded_data
123    }
124    fn pkcs7_pad(data: &[u8], block_size: usize) -> Vec<u8> {
125        let padding_len = block_size - (data.len() % block_size);
126        let mut padded = data.to_vec();
127        padded.extend(std::iter::repeat_n(padding_len as u8, padding_len));
128        padded
129    }
130    pub fn md5_hex(data: &str) -> String {
131        let mut hasher = Md5::new();
132        hasher.update(data.as_bytes());
133        let result = hasher.finalize();
134        hex::encode(result)
135    }
136    pub fn eapi_encrypt(url: &str, obj: &Value) -> String {
137        let text = serde_json::to_string(obj).unwrap_or_default();
138        let message = format!("nobody{}use{}md5forencrypt", url, text);
139        let digest = md5_hex(&message);
140        let data = format!("{}-36cd479b6b5-{}-36cd479b6b5-{}", url, text, digest);
141        let encrypted = aes_encrypt_ecb(data.as_bytes(), EAPI_KEY);
142        hex::encode(encrypted).to_uppercase()
143    }
144    pub async fn get_eapi_json<T: DeserializeOwned>(
145        client: &reqwest::Client,
146        path: &str,
147        obj: Value,
148        nuid: &str,
149        device_id: &str,
150    ) -> Option<T> {
151        let params = eapi_encrypt(path, &obj);
152        let network_path = path.strip_prefix("/api").unwrap_or(path);
153        for &base_url in EAPI_URLS {
154            let url = format!("{}{}", base_url, network_path);
155            let resp = client.post(&url)
156            .header("User-Agent", "NeteaseMusic/2.5.1 (iPhone; iOS 16.6; Scale/3.00)")
157            .header("Referer", "https://music.163.com/")
158            .header("Origin", "https://music.163.com")
159            .header("X-Real-IP", "118.88.88.88")
160            .header("X-Forwarded-For", "118.88.88.88")
161            .header("X-Netease-PC-IP", "118.88.88.88")
162            .header("Cookie", format!(
163                "os=iOS; appver=2.5.1; _ntes_nuid={}; deviceId={}; channel=AppStore; mobilename=iPhone15,3", 
164                nuid, device_id
165            ))
166            .form(&[("params", params.clone())])
167            .send()
168            .await;
169            match resp {
170                Ok(r) if r.status().is_success() => {
171                    let text = r.text().await.ok()?;
172                    match serde_json::from_str::<NeteaseResponse<T>>(&text) {
173                        Ok(res) => {
174                            if res.code == 200 || res.code == 0 {
175                                return Some(res.data);
176                            } else {
177                                debug!(
178                                    "Netease API {} returned application code {}: {}",
179                                    path, res.code, text
180                                );
181                            }
182                        }
183                        Err(e) => {
184                            error!(
185                                "Netease API {} failed to parse JSON: {}. Text: {}",
186                                path, e, text
187                            );
188                        }
189                    }
190                }
191                _ => continue,
192            }
193        }
194        None
195    }
196}
197pub mod manager {
198    use super::api::*;
199    use crate::protocol::tracks::{
200        LoadResult, PlaylistData, PlaylistInfo, SearchResult, Track, TrackInfo,
201    };
202    use serde_json::json;
203    pub fn parse_track(song: &NeteaseSong) -> Option<Track> {
204        let id = song.id.to_string();
205        let artist = song
206            .artists
207            .first()
208            .map(|a| a.name.as_str())
209            .unwrap_or("Unknown Artist");
210        let artwork_url = song.album.as_ref().and_then(|al| al.pic_url.clone());
211        Some(Track::new(TrackInfo {
212            identifier: id.clone(),
213            is_seekable: true,
214            author: artist.to_string(),
215            length: song.duration,
216            is_stream: false,
217            position: 0,
218            title: song.name.clone(),
219            uri: Some(format!("https://music.163.com/song?id={}", id)),
220            artwork_url,
221            isrc: None,
222            source_name: "netease".to_string(),
223        }))
224    }
225    pub fn parse_album(album: &NeteaseAlbum) -> PlaylistData {
226        PlaylistData {
227            info: PlaylistInfo {
228                name: album.name.clone(),
229                selected_track: -1,
230            },
231            plugin_info: json!({
232                "type": "album",
233                "url": format!("https://music.163.com/album?id={}", album.id),
234                "artworkUrl": album.pic_url,
235                "author": "Netease Music",
236                "totalTracks": 0
237            }),
238            tracks: Vec::new(),
239        }
240    }
241    pub fn parse_artist(artist: &NeteaseArtist) -> PlaylistData {
242        PlaylistData {
243            info: PlaylistInfo {
244                name: format!("{}'s Top Tracks", artist.name),
245                selected_track: -1,
246            },
247            plugin_info: json!({
248                "type": "artist",
249                "url": format!("https://music.163.com/artist?id={}", artist.id),
250                "artworkUrl": None::<String>,
251                "author": artist.name,
252                "totalTracks": 0
253            }),
254            tracks: Vec::new(),
255        }
256    }
257    pub fn parse_playlist(playlist: &NeteasePlaylist) -> PlaylistData {
258        PlaylistData {
259            info: PlaylistInfo {
260                name: playlist.name.clone(),
261                selected_track: -1,
262            },
263            plugin_info: json!({
264                "type": "playlist",
265                "url": format!("https://music.163.com/playlist?id={}", playlist.id),
266                "artworkUrl": playlist.cover_img_url,
267                "author": playlist.creator.as_ref().map(|c| c.nickname.clone()).unwrap_or_else(|| "Netease Music".to_string()),
268                "totalTracks": playlist.track_count.unwrap_or(0)
269            }),
270            tracks: Vec::new(),
271        }
272    }
273    #[derive(Debug, PartialEq)]
274    pub enum TrackUrlResult {
275        Success(String),
276        Code(i64),
277        Trial,
278        None,
279    }
280    pub async fn fetch_track_detail(
281        client: &reqwest::Client,
282        nuid: &str,
283        device_id: &str,
284        id: &str,
285    ) -> Option<SongDetailData> {
286        get_eapi_json(
287            client,
288            "/api/v3/song/detail",
289            json!({
290                "c": serde_json::to_string(&[json!({"id": id})]).unwrap(),
291                "header": {
292                    "os": "iOS",
293                    "appver": "2.5.1",
294                    "deviceId": device_id
295                }
296            }),
297            nuid,
298            device_id,
299        )
300        .await
301    }
302    pub async fn fetch_album(
303        client: &reqwest::Client,
304        nuid: &str,
305        device_id: &str,
306        id: &str,
307    ) -> LoadResult {
308        match get_eapi_json::<AlbumDetailData>(
309            client,
310            &format!("/api/v1/album/{}", id),
311            json!({}),
312            nuid,
313            device_id,
314        )
315        .await
316        {
317            Some(resp) if !resp.songs.is_empty() => {
318                let tracks = resp.songs.iter().filter_map(parse_track).collect();
319                LoadResult::Playlist(PlaylistData {
320                    info: PlaylistInfo {
321                        name: resp.album.name,
322                        selected_track: -1,
323                    },
324                    plugin_info: json!({ "type": "album", "id": id }),
325                    tracks,
326                })
327            }
328            _ => LoadResult::Empty {},
329        }
330    }
331    pub async fn fetch_playlist(
332        client: &reqwest::Client,
333        nuid: &str,
334        device_id: &str,
335        id: &str,
336    ) -> LoadResult {
337        match get_eapi_json::<PlaylistDetailData>(
338            client,
339            "/api/v6/playlist/detail",
340            json!({ "id": id, "n": 1000 }),
341            nuid,
342            device_id,
343        )
344        .await
345        {
346            Some(resp) if !resp.playlist.tracks.is_empty() => {
347                let tracks = resp
348                    .playlist
349                    .tracks
350                    .iter()
351                    .filter_map(parse_track)
352                    .collect();
353                LoadResult::Playlist(PlaylistData {
354                    info: PlaylistInfo {
355                        name: resp.playlist.name,
356                        selected_track: -1,
357                    },
358                    plugin_info: json!({ "type": "playlist", "id": id }),
359                    tracks,
360                })
361            }
362            _ => LoadResult::Empty {},
363        }
364    }
365    pub async fn fetch_artist(
366        client: &reqwest::Client,
367        nuid: &str,
368        device_id: &str,
369        id: &str,
370    ) -> LoadResult {
371        match get_eapi_json::<ArtistDetailData>(
372            client,
373            &format!("/api/v1/artist/{}", id),
374            json!({}),
375            nuid,
376            device_id,
377        )
378        .await
379        {
380            Some(resp) if !resp.hot_songs.is_empty() => {
381                let tracks = resp.hot_songs.iter().filter_map(parse_track).collect();
382                LoadResult::Playlist(PlaylistData {
383                    info: PlaylistInfo {
384                        name: format!("{}'s Top Tracks", resp.artist.name),
385                        selected_track: -1,
386                    },
387                    plugin_info: json!({ "type": "artist", "id": id }),
388                    tracks,
389                })
390            }
391            _ => LoadResult::Empty {},
392        }
393    }
394    pub async fn fetch_track_url(
395        client: &reqwest::Client,
396        nuid: &str,
397        device_id: &str,
398        id: &str,
399        level: &str,
400        encode_type: &str,
401    ) -> TrackUrlResult {
402        let resp: Option<TrackUrlData> = get_eapi_json(
403            client,
404            "/api/song/enhance/player/url/v1",
405            json!({
406                "ids": [id],
407                "level": level,
408                "encodeType": encode_type,
409                "header": {
410                    "os": "iOS",
411                    "appver": "2.5.1",
412                    "deviceId": device_id
413                }
414            }),
415            nuid,
416            device_id,
417        )
418        .await;
419        let data_wrap = match resp {
420            Some(v) => v,
421            None => return TrackUrlResult::None,
422        };
423        let data = match data_wrap.data.first() {
424            Some(d) => d,
425            None => return TrackUrlResult::None,
426        };
427        if data.code == -110 {
428            return TrackUrlResult::Code(-110);
429        }
430        if let Some(trial_info) = &data.free_trial_info
431            && trial_info.as_object().is_some_and(|o| !o.is_empty())
432        {
433            return TrackUrlResult::Trial;
434        }
435        if let Some(url) = &data.url
436            && !url.is_empty()
437        {
438            return TrackUrlResult::Success(url.clone());
439        }
440        if data.code != 200 && data.code != 0 {
441            TrackUrlResult::Code(data.code)
442        } else {
443            TrackUrlResult::None
444        }
445    }
446    pub async fn fetch_track_url_legacy(
447        client: &reqwest::Client,
448        device_id: &str,
449        id: &str,
450        br: &str,
451    ) -> Option<String> {
452        let url = format!(
453            "https://music.163.com/api/song/enhance/player/url?id={}&ids=[{}]&br={}",
454            id, id, br
455        );
456        let resp = client.get(&url)
457        .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36")
458        .header("Referer", "https://music.163.com/")
459        .header("X-Real-IP", "118.88.88.88")
460        .header("Cookie", format!("os=pc; deviceId={}", device_id))
461        .send()
462        .await
463        .ok()?;
464        if !resp.status().is_success() {
465            return None;
466        }
467        let res: NeteaseResponse<TrackUrlData> = resp.json().await.ok()?;
468        let data = res.data.data.first()?;
469        if let Some(url) = &data.url
470            && !url.is_empty()
471        {
472            return Some(url.clone());
473        }
474        None
475    }
476    pub async fn check_url(client: &reqwest::Client, url: &str) -> bool {
477        let resp = match client.head(url)
478        .header("User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 CloudMusic/2.5.1")
479        .header("Referer", "https://music.163.com/")
480        .send()
481        .await
482    {
483        Ok(r) => r,
484        Err(_) => return false,
485    };
486        if !resp.status().is_success() {
487            return false;
488        }
489        let content_length = resp
490            .headers()
491            .get(reqwest::header::CONTENT_LENGTH)
492            .and_then(|h| h.to_str().ok())
493            .and_then(|s| s.parse::<u64>().ok())
494            .unwrap_or(0);
495        content_length >= 100 * 1024
496    }
497    pub async fn search_tracks(
498        client: &reqwest::Client,
499        nuid: &str,
500        device_id: &str,
501        query: &str,
502        limit: usize,
503    ) -> LoadResult {
504        match get_eapi_json::<SearchResultData>(
505            client,
506            "/api/cloudsearch/pc",
507            json!({
508                "s": query,
509                "type": 1,
510                "limit": limit,
511                "offset": 0,
512                "total": true
513            }),
514            nuid,
515            device_id,
516        )
517        .await
518        {
519            Some(resp) if !resp.result.songs.is_empty() => {
520                let tracks = resp.result.songs.iter().filter_map(parse_track).collect();
521                LoadResult::Search(tracks)
522            }
523            _ => LoadResult::Empty {},
524        }
525    }
526    pub async fn search_full(
527        client: &reqwest::Client,
528        nuid: &str,
529        device_id: &str,
530        query: &str,
531        types: &[String],
532        limit: usize,
533    ) -> Option<SearchResult> {
534        let mut tracks = Vec::new();
535        let mut albums = Vec::new();
536        let mut artists = Vec::new();
537        let mut playlists = Vec::new();
538        let all_types = types.is_empty();
539        if (all_types || types.contains(&"track".to_owned()))
540            && let Some(res) = fetch_search(client, nuid, device_id, query, 1, limit).await
541        {
542            tracks = res.songs.iter().filter_map(parse_track).collect();
543        }
544        if (all_types || types.contains(&"album".to_owned()))
545            && let Some(res) = fetch_search(client, nuid, device_id, query, 10, limit).await
546        {
547            albums = res.albums.iter().map(parse_album).collect();
548        }
549        if (all_types || types.contains(&"artist".to_owned()))
550            && let Some(res) = fetch_search(client, nuid, device_id, query, 100, limit).await
551        {
552            artists = res.artists.iter().map(parse_artist).collect();
553        }
554        if (all_types || types.contains(&"playlist".to_owned()))
555            && let Some(res) = fetch_search(client, nuid, device_id, query, 1000, limit).await
556        {
557            playlists = res.playlists.iter().map(parse_playlist).collect();
558        }
559        Some(SearchResult {
560            tracks,
561            albums,
562            artists,
563            playlists,
564            texts: Vec::new(),
565            plugin: json!({}),
566        })
567    }
568    async fn fetch_search(
569        client: &reqwest::Client,
570        nuid: &str,
571        device_id: &str,
572        query: &str,
573        type_id: i32,
574        limit: usize,
575    ) -> Option<SearchResultInner> {
576        get_eapi_json::<SearchResultData>(
577            client,
578            "/api/cloudsearch/pc",
579            json!({
580                "s": query,
581                "type": type_id,
582                "limit": limit,
583                "offset": 0,
584                "total": true
585            }),
586            nuid,
587            device_id,
588        )
589        .await
590        .map(|d| d.result)
591    }
592    pub async fn fetch_recommendations(
593        client: &reqwest::Client,
594        nuid: &str,
595        device_id: &str,
596        identifier: &str,
597    ) -> LoadResult {
598        let id = identifier.trim();
599        if !id.chars().all(|c| c.is_ascii_digit()) || id.is_empty() {
600            return LoadResult::Empty {};
601        }
602        match get_eapi_json::<SimilarSongsData>(
603            client,
604            "/api/v1/discovery/simiSong",
605            json!({ "songid": id }),
606            nuid,
607            device_id,
608        )
609        .await
610        {
611            Some(resp) if !resp.songs.is_empty() => {
612                let tracks = resp.songs.iter().filter_map(parse_track).collect();
613                LoadResult::Playlist(PlaylistData {
614                    info: PlaylistInfo {
615                        name: format!("Similar to Track {}", id),
616                        selected_track: -1,
617                    },
618                    plugin_info: json!({ "type": "recommendations", "seed_id": id }),
619                    tracks,
620                })
621            }
622            _ => LoadResult::Empty {},
623        }
624    }
625}
626pub mod track {
627    use crate::{
628        config::HttpProxyConfig,
629        sources::{
630            http::HttpTrack,
631            playable_track::{PlayableTrack, ResolvedTrack},
632        },
633    };
634    use async_trait::async_trait;
635    use std::net::IpAddr;
636    use tracing::debug;
637    pub struct NeteaseTrack {
638        pub stream_url: String,
639        pub local_addr: Option<IpAddr>,
640        pub proxy: Option<HttpProxyConfig>,
641    }
642    #[async_trait]
643    impl PlayableTrack for NeteaseTrack {
644        async fn resolve(&self) -> Result<ResolvedTrack, String> {
645            let url = self.stream_url.clone();
646            debug!("Netease playback URL: {url}");
647            HttpTrack {
648                url,
649                local_addr: self.local_addr,
650                proxy: None,
651            }
652            .resolve()
653            .await
654        }
655    }
656}
657use crate::{
658    protocol::tracks::{LoadResult, SearchResult},
659    sources::{SourcePlugin, playable_track::BoxedTrack},
660};
661use async_trait::async_trait;
662use rand::Rng;
663use regex::Regex;
664use std::sync::{Arc, OnceLock};
665use tracing::debug;
666fn url_regex() -> &'static Regex {
667    static REGEX: OnceLock<Regex> = OnceLock::new();
668    REGEX.get_or_init(|| {
669        Regex::new(r"https?://music\.163\.com/(?:(?:#|m)/)?(?P<type>song|album|playlist|artist)(?:\?id=|\/)(?P<id>\d+)").unwrap()
670    })
671}
672pub struct NeteaseSource {
673    pub(crate) client: Arc<reqwest::Client>,
674    pub(crate) proxy: Option<crate::config::HttpProxyConfig>,
675    pub(crate) search_limit: usize,
676    pub(crate) nuid: String,
677    pub(crate) device_id: String,
678}
679impl NeteaseSource {
680    pub fn new(
681        config: Option<crate::config::NeteaseMusicConfig>,
682        client: Arc<reqwest::Client>,
683    ) -> Result<Self, String> {
684        let cfg = config.ok_or("Netease Music configuration is missing")?;
685        let mut rng = rand::thread_rng();
686        let nuid: String = (0..16)
687            .map(|_| format!("{:02x}", rng.r#gen::<u8>()))
688            .collect::<Vec<String>>()
689            .join("");
690        let device_id: String = (0..8)
691            .map(|_| format!("{:02X}", rng.r#gen::<u8>()))
692            .collect::<Vec<String>>()
693            .join("");
694        Ok(Self {
695            client,
696            proxy: cfg.proxy,
697            search_limit: cfg.search_limit,
698            nuid,
699            device_id,
700        })
701    }
702}
703#[async_trait]
704impl SourcePlugin for NeteaseSource {
705    fn name(&self) -> &str {
706        "netease"
707    }
708    fn can_handle(&self, identifier: &str) -> bool {
709        self.search_prefixes()
710            .iter()
711            .any(|p| identifier.starts_with(p))
712            || self
713                .rec_prefixes()
714                .iter()
715                .any(|p| identifier.starts_with(p))
716            || url_regex().is_match(identifier)
717    }
718    fn search_prefixes(&self) -> Vec<&str> {
719        vec!["nmsearch:", "ncsearch:"]
720    }
721    fn rec_prefixes(&self) -> Vec<&str> {
722        vec!["nmrec:", "ncrec:"]
723    }
724    async fn load(
725        &self,
726        identifier: &str,
727        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
728    ) -> LoadResult {
729        for prefix in self.search_prefixes() {
730            if let Some(query) = identifier.strip_prefix(prefix) {
731                return manager::search_tracks(
732                    &self.client,
733                    &self.nuid,
734                    &self.device_id,
735                    query,
736                    self.search_limit,
737                )
738                .await;
739            }
740        }
741        for prefix in self.rec_prefixes() {
742            if let Some(query) = identifier.strip_prefix(prefix) {
743                return manager::fetch_recommendations(
744                    &self.client,
745                    &self.nuid,
746                    &self.device_id,
747                    query,
748                )
749                .await;
750            }
751        }
752        if let Some(caps) = url_regex().captures(identifier) {
753            let type_ = caps.name("type").map(|m| m.as_str()).unwrap_or("");
754            let id = caps.name("id").map(|m| m.as_str()).unwrap_or("");
755            match type_ {
756                "song" => {
757                    if let Some(detail) =
758                        manager::fetch_track_detail(&self.client, &self.nuid, &self.device_id, id)
759                            .await
760                        && let Some(song) = detail.songs.first()
761                        && let Some(track) = manager::parse_track(song)
762                    {
763                        return LoadResult::Track(track);
764                    }
765                }
766                "album" => {
767                    return manager::fetch_album(&self.client, &self.nuid, &self.device_id, id)
768                        .await;
769                }
770                "playlist" => {
771                    return manager::fetch_playlist(&self.client, &self.nuid, &self.device_id, id)
772                        .await;
773                }
774                "artist" => {
775                    return manager::fetch_artist(&self.client, &self.nuid, &self.device_id, id)
776                        .await;
777                }
778                _ => {}
779            }
780            return LoadResult::Empty {};
781        }
782        if identifier.chars().all(|c| c.is_ascii_digit())
783            && !identifier.is_empty()
784            && let Some(detail) =
785                manager::fetch_track_detail(&self.client, &self.nuid, &self.device_id, identifier)
786                    .await
787            && let Some(song) = detail.songs.first()
788            && let Some(track) = manager::parse_track(song)
789        {
790            return LoadResult::Track(track);
791        }
792        manager::search_tracks(
793            &self.client,
794            &self.nuid,
795            &self.device_id,
796            identifier,
797            self.search_limit,
798        )
799        .await
800    }
801    async fn load_search(
802        &self,
803        query: &str,
804        _types: &[String],
805        _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
806    ) -> Option<SearchResult> {
807        let mut q = query;
808        for prefix in self.search_prefixes() {
809            if let Some(stripped) = query.strip_prefix(prefix) {
810                q = stripped;
811                break;
812            }
813        }
814        manager::search_full(
815            &self.client,
816            &self.nuid,
817            &self.device_id,
818            q,
819            _types,
820            self.search_limit,
821        )
822        .await
823    }
824    async fn get_track(
825        &self,
826        identifier: &str,
827        routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
828    ) -> Option<BoxedTrack> {
829        let id = url_regex()
830            .captures(identifier)
831            .and_then(|caps| caps.name("id"))
832            .map(|m| m.as_str())
833            .unwrap_or(identifier);
834        debug!("Netease: Resolving track ID: {}", id);
835        let mut stream_url = None;
836        let mut fallback_early = false;
837        let qualities = [
838            ("aac", "standard"),
839            ("aac", "higher"),
840            ("aac", "exhigh"),
841            ("aac", "lossless"),
842            ("aac", "hires"),
843            ("aac", "jymaster"),
844            ("aac", "sky"),
845            ("aac", "jyeffect"),
846            ("aac", "jylive"),
847            ("mp3", "standard"),
848            ("mp3", "higher"),
849            ("mp3", "exhigh"),
850            ("mp3", "lossless"),
851            ("mp3", "hires"),
852            ("mp3", "jymaster"),
853            ("mp3", "sky"),
854            ("mp3", "jyeffect"),
855            ("mp3", "jylive"),
856        ];
857        let mut first_code: Option<i64> = None;
858        for (format, level) in qualities {
859            match manager::fetch_track_url(
860                &self.client,
861                &self.nuid,
862                &self.device_id,
863                id,
864                level,
865                format,
866            )
867            .await
868            {
869                manager::TrackUrlResult::Success(url)
870                    if manager::check_url(&self.client, &url).await =>
871                {
872                    stream_url = Some(url);
873                    break;
874                }
875                manager::TrackUrlResult::Code(-110) => {
876                    fallback_early = true;
877                    break;
878                }
879                manager::TrackUrlResult::Trial => {
880                    debug!("Netease: Track {} is trial-only, skipping quality loop", id);
881                    fallback_early = true;
882                    break;
883                }
884                manager::TrackUrlResult::Code(c) => {
885                    first_code = first_code.or(Some(c));
886                    continue;
887                }
888                _ => continue,
889            }
890        }
891        if stream_url.is_none() || fallback_early {
892            for br in ["320000", "128000"] {
893                if let Some(url) =
894                    manager::fetch_track_url_legacy(&self.client, &self.device_id, id, br).await
895                    && !url.is_empty()
896                    && manager::check_url(&self.client, &url).await
897                {
898                    stream_url = Some(url);
899                    break;
900                }
901            }
902        }
903        if stream_url.is_none() {
904            debug!(
905                "Netease: Failed to resolve playback URL for track ID: {}",
906                id
907            );
908        }
909        stream_url.map(|url| {
910            Arc::new(track::NeteaseTrack {
911                stream_url: url,
912                proxy: self.proxy.clone(),
913                local_addr: routeplanner.and_then(|rp| rp.get_address()),
914            }) as BoxedTrack
915        })
916    }
917    fn get_proxy_config(&self) -> Option<crate::config::HttpProxyConfig> {
918        self.proxy.clone()
919    }
920}