unm_engine_pyncm/
lib.rs

1//! UNM Engine: PyNCM
2//!
3//! It can fetch audio from the unofficial
4//! Netease Cloud Music API.
5
6use http::header::HOST;
7use log::{debug, info};
8use serde::Deserialize;
9use unm_engine::interface::Engine;
10use unm_request::build_client;
11use unm_types::{Context, RetrievedSongInfo, SerializedIdentifier, Song, SongSearchInformation};
12use url::Url;
13
14#[derive(Deserialize)]
15#[non_exhaustive]
16struct PyNCMResponse {
17    /// The status code of this response.
18    pub code: i32,
19    pub data: Vec<PyNCMResponseEntry>,
20}
21
22#[derive(Deserialize)]
23#[non_exhaustive]
24struct PyNCMResponseEntry {
25    /// The NCM ID of this song.
26    pub id: i64,
27    /// The URL of this song.
28    pub url: Option<String>,
29}
30
31pub const ENGINE_ID: &str = "pyncm";
32
33/// The `pyncm` engine that can fetch audio from
34/// the unofficial Netease Cloud Music API.
35pub struct PyNCMEngine;
36
37#[async_trait::async_trait]
38impl Engine for PyNCMEngine {
39    async fn search<'a>(
40        &self,
41        info: &'a Song,
42        ctx: &'a Context,
43    ) -> anyhow::Result<Option<SongSearchInformation>> {
44        info!("Searching with PyNCM engine…");
45
46        let response = fetch_song_info(&info.id, ctx).await?;
47
48        if response.code == 200 {
49            // We return the URL we got from PyNCM as the song identifier,
50            // so we can return the URL in retrieve() easily.
51            let match_result = find_match(&response.data, &info.id)?.map(|url| {
52                SongSearchInformation::builder()
53                    .source(ENGINE_ID.into())
54                    .identifier(url)
55                    .build()
56            });
57
58            Ok(match_result)
59        } else {
60            Err(anyhow::anyhow!(
61                "failed to request. code: {}",
62                response.code
63            ))
64        }
65    }
66
67    async fn retrieve<'a>(
68        &self,
69        identifier: &'a SerializedIdentifier,
70        _: &'a Context,
71    ) -> anyhow::Result<RetrievedSongInfo> {
72        info!("Retrieving with PyNCM engine…");
73
74        // We just return the identifier as the URL of song.
75        Ok(RetrievedSongInfo::builder()
76            .source(ENGINE_ID.into())
77            .url(identifier.to_string())
78            .build())
79    }
80}
81
82/// Fetch the song info in [`PyNCMResponse`].
83async fn fetch_song_info(id: &str, ctx: &Context) -> anyhow::Result<PyNCMResponse> {
84    debug!("Fetching the song information…");
85
86    let bitrate = if ctx.enable_flac { 999000 } else { 320000 };
87    let url = Url::parse_with_params(
88        "http://76.76.21.21/api/pyncm?module=track&method=GetTrackAudio",
89        &[("song_ids", id), ("bitrate", &bitrate.to_string())],
90    )?;
91
92    let client = build_client(ctx.proxy_uri.as_deref())?;
93    let response = client
94        .get(url)
95        .header(HOST, "music.163-my-beloved.com")
96        .send()
97        .await?;
98    Ok(response.json::<PyNCMResponse>().await?)
99}
100
101/// Find the matched song from an array of [`PyNCMResponseEntry`].
102fn find_match(data: &[PyNCMResponseEntry], song_id: &str) -> anyhow::Result<Option<String>> {
103    info!("Finding the matched song…");
104
105    data.iter()
106        .find(|entry| {
107            // Test if the ID of this entry matched what we want to fetch,
108            // and there is content in its URL.
109            entry.id.to_string() == song_id && entry.url.is_some()
110        })
111        .map(|v| v.url.clone())
112        .ok_or_else(|| anyhow::anyhow!("no matched song"))
113}
114
115#[cfg(test)]
116mod tests {
117    use unm_types::ContextBuilder;
118
119    #[tokio::test]
120    async fn test_fetch_song_info() {
121        use super::fetch_song_info;
122
123        let song_id = "1939601619"; // Madeon – Love You Back
124        let result = fetch_song_info(song_id, &ContextBuilder::default().build().unwrap()).await;
125
126        if let Ok(response) = result {
127            assert_eq!(response.code, 200);
128            assert_eq!(response.data.len(), 1);
129            assert_eq!(response.data[0].id.to_string(), song_id);
130            assert!(response.data[0].url.is_some());
131        } else {
132            panic!("failed to fetch song info");
133        }
134    }
135}