Skip to main content

ncm_api_rs/api/
voice_upload.rs

1use super::Query;
2use crate::error::Result;
3/// 上传音频(声音)
4/// 对应 Node.js module/voice_upload.js
5///
6/// 注意: 此端点涉及复杂的分块上传流程(multipart upload 到 NOS 对象存储)。
7/// 由于 Rust SDK 是纯库,文件读取由调用者负责,需传入完整的音频二进制数据。
8use crate::request::{ApiClient, ApiResponse, CryptoType};
9use serde_json::json;
10
11impl ApiClient {
12    /// 上传音频文件
13    ///
14    /// query 参数:
15    /// - `songName`: 歌曲名(可选,默认从文件名推断)
16    /// - `autoPublish`: 是否自动发布 (1=是, 0=否)
17    /// - `autoPublishText`: 自动发布文案
18    /// - `description`: 描述
19    /// - `voiceListId`: 声音列表 ID
20    /// - `coverImgId`: 封面图 ID
21    /// - `categoryId`: 分类 ID
22    /// - `secondCategoryId`: 二级分类 ID
23    /// - `composedSongs`: 关联歌曲 ID,逗号分隔
24    /// - `privacy`: 是否私密 (1=是, 0=否)
25    /// - `publishTime`: 发布时间
26    /// - `orderNo`: 排序号
27    ///
28    /// `file_name`: 原始文件名(如 "song.mp3")
29    /// `file_data`: 音频文件的完整二进制数据
30    /// `file_mimetype`: MIME 类型,如 "audio/mpeg"
31    pub async fn voice_upload(
32        &self,
33        query: &Query,
34        file_name: &str,
35        file_data: Vec<u8>,
36        file_mimetype: Option<&str>,
37    ) -> Result<ApiResponse> {
38        let mimetype = file_mimetype.unwrap_or("audio/mpeg");
39
40        // 提取文件扩展名
41        let ext = file_name.rsplit('.').next().unwrap_or("mp3");
42
43        // 推断文件名
44        let filename = if let Some(name) = query.get("songName") {
45            name.to_string()
46        } else {
47            file_name
48                .rsplit('.')
49                .next_back()
50                .unwrap_or(file_name)
51                .replace(' ', "")
52                .replace('.', "_")
53        };
54
55        // Step 1: 申请上传 token
56        let token_data = json!({
57            "bucket": "ymusic",
58            "ext": ext,
59            "filename": filename,
60            "local": false,
61            "nos_product": 0,
62            "type": "other"
63        });
64
65        let token_res = self
66            .request(
67                "/api/nos/token/alloc",
68                token_data,
69                query.to_option(CryptoType::Weapi),
70            )
71            .await?;
72
73        let result = &token_res.body["result"];
74        let object_key_raw = result["objectKey"].as_str().unwrap_or_default();
75        let object_key = object_key_raw.replace('/', "%2F");
76        let token = result["token"].as_str().unwrap_or_default().to_string();
77        let doc_id = result["docId"].clone();
78
79        // Step 2: 初始化 multipart upload
80        let init_url = format!("https://ymusic.nos-hz.163yun.com/{}?uploads", object_key);
81
82        let init_res = self
83            .client
84            .post(&init_url)
85            .header("x-nos-token", &token)
86            .header("X-Nos-Meta-Content-Type", mimetype)
87            .send()
88            .await
89            .map_err(crate::error::NcmError::Http)?;
90
91        let init_xml = init_res
92            .text()
93            .await
94            .map_err(crate::error::NcmError::Http)?;
95
96        // 简单解析 XML 获取 UploadId
97        let upload_id = init_xml
98            .split("<UploadId>")
99            .nth(1)
100            .and_then(|s| s.split("</UploadId>").next())
101            .unwrap_or_default()
102            .to_string();
103
104        // Step 3: 分块上传(每块 10MB)
105        let block_size = 10 * 1024 * 1024;
106        let file_size = file_data.len();
107        let mut offset = 0usize;
108        let mut block_index = 1u32;
109        let mut etags = Vec::new();
110
111        while offset < file_size {
112            let end = (offset + block_size).min(file_size);
113            let chunk = file_data[offset..end].to_vec();
114
115            let part_url = format!(
116                "https://ymusic.nos-hz.163yun.com/{}?partNumber={}&uploadId={}",
117                object_key, block_index, upload_id
118            );
119
120            let part_res = self
121                .client
122                .put(&part_url)
123                .header("x-nos-token", &token)
124                .header("Content-Type", mimetype)
125                .body(chunk)
126                .send()
127                .await
128                .map_err(crate::error::NcmError::Http)?;
129
130            if let Some(etag) = part_res.headers().get("etag") {
131                etags.push(etag.to_str().unwrap_or_default().to_string());
132            }
133
134            offset = end;
135            block_index += 1;
136        }
137
138        // Step 4: 完成 multipart upload
139        let mut complete_xml = String::from("<CompleteMultipartUpload>");
140        for (i, etag) in etags.iter().enumerate() {
141            complete_xml.push_str(&format!(
142                "<Part><PartNumber>{}</PartNumber><ETag>{}</ETag></Part>",
143                i + 1,
144                etag
145            ));
146        }
147        complete_xml.push_str("</CompleteMultipartUpload>");
148
149        let complete_url = format!(
150            "https://ymusic.nos-hz.163yun.com/{}?uploadId={}",
151            object_key, upload_id
152        );
153
154        self.client
155            .post(&complete_url)
156            .header("Content-Type", "text/plain;charset=UTF-8")
157            .header("X-Nos-Meta-Content-Type", mimetype)
158            .header("x-nos-token", &token)
159            .body(complete_xml)
160            .send()
161            .await
162            .map_err(crate::error::NcmError::Http)?;
163
164        // Step 5: 生成 UUID 风格的 dupkey
165        let dupkey = generate_dupkey();
166
167        let auto_publish = query.get("autoPublish").map(|v| v == "1").unwrap_or(false);
168        let privacy = query.get("privacy").map(|v| v == "1").unwrap_or(false);
169        let composed_songs: Vec<String> = query
170            .get("composedSongs")
171            .map(|s| s.split(',').map(|x| x.to_string()).collect())
172            .unwrap_or_default();
173
174        let voice_data = json!([{
175            "name": filename,
176            "autoPublish": auto_publish,
177            "autoPublishText": query.get_or("autoPublishText", ""),
178            "description": query.get_or("description", ""),
179            "voiceListId": query.get_or("voiceListId", ""),
180            "coverImgId": query.get_or("coverImgId", ""),
181            "dfsId": doc_id,
182            "categoryId": query.get_or("categoryId", ""),
183            "secondCategoryId": query.get_or("secondCategoryId", ""),
184            "composedSongs": composed_songs,
185            "privacy": privacy,
186            "publishTime": query.get_or("publishTime", "0").parse::<i64>().unwrap_or(0),
187            "orderNo": query.get_or("orderNo", "1").parse::<i64>().unwrap_or(1),
188        }]);
189
190        let voice_data_str = serde_json::to_string(&voice_data).unwrap_or_default();
191
192        // Step 6: preCheck
193        let _pre_check = self
194            .request(
195                "/api/voice/workbench/voice/batch/upload/preCheck",
196                json!({
197                    "dupkey": generate_dupkey(),
198                    "voiceData": voice_data_str
199                }),
200                query.to_option(CryptoType::default()),
201            )
202            .await?;
203
204        // Step 7: 实际上传
205        let upload_result = self
206            .request(
207                "/api/voice/workbench/voice/batch/upload/v2",
208                json!({
209                    "dupkey": dupkey,
210                    "voiceData": voice_data_str
211                }),
212                query.to_option(CryptoType::default()),
213            )
214            .await?;
215
216        Ok(ApiResponse {
217            status: 200,
218            body: json!({
219                "code": 200,
220                "data": upload_result.body["data"]
221            }),
222            cookie: upload_result.cookie,
223        })
224    }
225}
226
227/// 生成 UUID v4 风格的 dupkey
228fn generate_dupkey() -> String {
229    use rand::Rng;
230    let hex_digits = b"0123456789abcdef";
231    let mut rng = rand::thread_rng();
232    let mut s = [0u8; 36];
233
234    for item in &mut s {
235        *item = hex_digits[rng.gen_range(0..16)];
236    }
237    s[14] = b'4';
238    s[19] = hex_digits[((s[19] & 0x3) | 0x8) as usize];
239    s[8] = b'-';
240    s[13] = b'-';
241    s[18] = b'-';
242    s[23] = b'-';
243
244    String::from_utf8_lossy(&s).to_string()
245}