mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced.git
synced 2026-03-21 11:03:15 +00:00
feat(cloud): 添加云盘上传模式选择和进度显示功能
- 添加客户端直传和后端代理两种上传模式选项 - 实现上传进度条和状态显示界面 - 添加文件MD5计算和上传凭证获取功能 - 支持大文件上传和断点续传机制 - 新增cloud/upload/token和cloud/upload/complete接口 - 更新文档说明上传模式和接口使用方法 - 优化上传按钮禁用状态和提示信息显示
This commit is contained in:
parent
84efe5d758
commit
83c527af01
@ -101,7 +101,7 @@ $ sudo docker run -d -p 3000:3000 ncm-api
|
||||
## 3. 环境变量
|
||||
|
||||
| 变量名 | 默认值 | 说明 |
|
||||
| -------------------------- | ------------------------------------ | ------------------------------------------------------------------------------ |
|
||||
|----------------------------|--------------------------------------|----------------------------------------------------|
|
||||
| **CORS_ALLOW_ORIGIN** | `*` | 允许跨域请求的域名。若需要限制,请指定具体域名(例如 `https://example.com`)。 |
|
||||
| **ENABLE_PROXY** | `false` | 是否启用反向代理功能。 |
|
||||
| **PROXY_URL** | `https://your-proxy-url.com/?proxy=` | 代理服务地址。仅当 `ENABLE_PROXY=true` 时生效。 |
|
||||
@ -209,7 +209,7 @@ pnpm test
|
||||
### SDK 生态
|
||||
|
||||
| 语言 | 作者 | 地址 | 类型 |
|
||||
| ------ | ------------------------------------------- | ---------------------------------------------------------------------------------------- | ------ |
|
||||
|--------|---------------------------------------------|------------------------------------------------------------------------------------------|-----|
|
||||
| Java | [JackuXL](https://github.com/JackuXL) | [NeteaseCloudMusicApi-SDK](https://github.com/JackuXL/NeteaseCloudMusicApi-SDK) | 第三方 |
|
||||
| Java | [1015770492](https://github.com/1015770492) | https://github.com/1015770492/yumbo-music-utils | 第三方 |
|
||||
| Python | [盧瞳](https://github.com/2061360308) | [NeteaseCloudMusic_PythonSDK](https://github.com/2061360308/NeteaseCloudMusic_PythonSDK) | 第三方 |
|
||||
|
||||
73
module/cloud_upload_complete.js
Normal file
73
module/cloud_upload_complete.js
Normal file
@ -0,0 +1,73 @@
|
||||
const createOption = require('../util/option.js')
|
||||
|
||||
module.exports = async (query, request) => {
|
||||
const {
|
||||
songId,
|
||||
resourceId,
|
||||
md5,
|
||||
filename,
|
||||
song,
|
||||
artist,
|
||||
album,
|
||||
bitrate = 999000,
|
||||
} = query
|
||||
|
||||
if (!songId || !resourceId || !md5 || !filename) {
|
||||
return Promise.reject({
|
||||
status: 400,
|
||||
body: {
|
||||
code: 400,
|
||||
msg: '缺少必要参数: songId, resourceId, md5, filename',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const songName = song || filename.replace(/\.[^.]+$/, '')
|
||||
const ext = filename.includes('.') ? filename.split('.').pop() : 'mp3'
|
||||
|
||||
const res2 = await request(
|
||||
`/api/upload/cloud/info/v2`,
|
||||
{
|
||||
md5: md5,
|
||||
songid: songId,
|
||||
filename: filename,
|
||||
song: songName,
|
||||
album: album || '未知专辑',
|
||||
artist: artist || '未知艺术家',
|
||||
bitrate: String(bitrate),
|
||||
resourceId: resourceId,
|
||||
},
|
||||
createOption(query),
|
||||
)
|
||||
|
||||
if (res2.body.code !== 200) {
|
||||
return Promise.reject({
|
||||
status: res2.status || 500,
|
||||
body: {
|
||||
code: res2.body.code || 500,
|
||||
msg: res2.body.msg || '上传云盘信息失败',
|
||||
detail: res2.body,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const res3 = await request(
|
||||
`/api/cloud/pub/v2`,
|
||||
{
|
||||
songid: res2.body.songId,
|
||||
},
|
||||
createOption(query),
|
||||
)
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: 200,
|
||||
data: {
|
||||
songId: res2.body.songId,
|
||||
...res3.body,
|
||||
},
|
||||
},
|
||||
cookie: res2.cookie,
|
||||
}
|
||||
}
|
||||
109
module/cloud_upload_token.js
Normal file
109
module/cloud_upload_token.js
Normal file
@ -0,0 +1,109 @@
|
||||
const createOption = require('../util/option.js')
|
||||
const crypto = require('crypto')
|
||||
|
||||
module.exports = async (query, request) => {
|
||||
const { md5, fileSize, filename, bitrate = 999000 } = query
|
||||
|
||||
if (!md5 || !fileSize || !filename) {
|
||||
return Promise.reject({
|
||||
status: 400,
|
||||
body: {
|
||||
code: 400,
|
||||
msg: '缺少必要参数: md5, fileSize, filename',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const ext = filename.includes('.') ? filename.split('.').pop() : 'mp3'
|
||||
|
||||
const checkRes = await request(
|
||||
`/api/cloud/upload/check`,
|
||||
{
|
||||
bitrate: String(bitrate),
|
||||
ext: '',
|
||||
length: fileSize,
|
||||
md5: md5,
|
||||
songId: '0',
|
||||
version: 1,
|
||||
},
|
||||
createOption(query),
|
||||
)
|
||||
|
||||
const bucket = 'jd-musicrep-privatecloud-audio-public'
|
||||
const tokenRes = await request(
|
||||
`/api/nos/token/alloc`,
|
||||
{
|
||||
bucket: bucket,
|
||||
ext: ext,
|
||||
filename: filename.replace(/\.[^.]+$/, '').replace(/\s/g, '').replace(/\./g, '_'),
|
||||
local: false,
|
||||
nos_product: 3,
|
||||
type: 'audio',
|
||||
md5: md5,
|
||||
},
|
||||
createOption(query, 'weapi'),
|
||||
)
|
||||
|
||||
if (!tokenRes.body.result || !tokenRes.body.result.objectKey) {
|
||||
return Promise.reject({
|
||||
status: 500,
|
||||
body: {
|
||||
code: 500,
|
||||
msg: '获取上传token失败',
|
||||
detail: tokenRes.body,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const { default: axios } = require('axios')
|
||||
let lbs
|
||||
try {
|
||||
lbs = (
|
||||
await axios({
|
||||
method: 'get',
|
||||
url: `https://wanproxy.127.net/lbs?version=1.0&bucketname=${bucket}`,
|
||||
timeout: 10000,
|
||||
})
|
||||
).data
|
||||
} catch (error) {
|
||||
return Promise.reject({
|
||||
status: 500,
|
||||
body: {
|
||||
code: 500,
|
||||
msg: '获取上传服务器地址失败',
|
||||
detail: error.message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!lbs || !lbs.upload || !lbs.upload[0]) {
|
||||
return Promise.reject({
|
||||
status: 500,
|
||||
body: {
|
||||
code: 500,
|
||||
msg: '获取上传服务器地址无效',
|
||||
detail: lbs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
code: 200,
|
||||
data: {
|
||||
needUpload: checkRes.body.needUpload,
|
||||
songId: checkRes.body.songId,
|
||||
uploadToken: tokenRes.body.result.token,
|
||||
objectKey: tokenRes.body.result.objectKey,
|
||||
resourceId: tokenRes.body.result.resourceId,
|
||||
uploadUrl: `${lbs.upload[0]}/${bucket}/${tokenRes.body.result.objectKey.replace('/', '%2F')}?offset=0&complete=true&version=1.0`,
|
||||
bucket: bucket,
|
||||
md5: md5,
|
||||
fileSize: fileSize,
|
||||
filename: filename,
|
||||
},
|
||||
},
|
||||
cookie: checkRes.cookie,
|
||||
}
|
||||
}
|
||||
@ -47,6 +47,59 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mode-section {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mode-section label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mode-options {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mode-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mode-option input[type="radio"] {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.mode-option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mode-option-title {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.mode-option-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.mode-option input[type="radio"]:checked + .mode-option-text .mode-option-title {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@ -72,6 +125,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-btn.disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.songs-list {
|
||||
list-style: none;
|
||||
}
|
||||
@ -99,6 +157,74 @@
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-bottom: 24px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.progress-section.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.progress-item {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.progress-item .name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.progress-item .status {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar .fill {
|
||||
height: 100%;
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.progress-item.success .fill {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.progress-item.error .fill {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
.progress-item.error .status {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
font-size: 12px;
|
||||
color: #e65100;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@ -107,13 +233,36 @@
|
||||
<h1>云盘上传</h1>
|
||||
<a href="/qrlogin-nocookie.html" class="login-link">还没登录?点击登录</a>
|
||||
|
||||
<div class="mode-section">
|
||||
<label>上传模式</label>
|
||||
<div class="mode-options">
|
||||
<label class="mode-option">
|
||||
<input type="radio" name="uploadMode" value="direct" checked />
|
||||
<span class="mode-option-text">
|
||||
<span class="mode-option-title">客户端直传</span>
|
||||
<span class="mode-option-desc">文件直接上传到云存储,支持大文件,适合 Vercel 等平台</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="mode-option">
|
||||
<input type="radio" name="uploadMode" value="proxy" />
|
||||
<span class="mode-option-text">
|
||||
<span class="mode-option-title">后端代理</span>
|
||||
<span class="mode-option-desc">文件通过服务器转发,更简洁,需要服务器支持大文件</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-section">
|
||||
<label class="upload-btn">
|
||||
<label class="upload-btn" id="uploadBtn">
|
||||
选择文件(支持多选)
|
||||
<input id="file" type="file" multiple accept="audio/*" />
|
||||
</label>
|
||||
<p class="info-text" id="modeInfo">支持大文件上传,文件将直接传输到云存储服务器</p>
|
||||
</div>
|
||||
|
||||
<div id="progressSection" class="progress-section"></div>
|
||||
|
||||
<div id="app">
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
<ul v-else-if="songs.length > 0" class="songs-list">
|
||||
@ -157,55 +306,247 @@
|
||||
},
|
||||
}).mount('#app')
|
||||
|
||||
const fileUpdateTime = {}
|
||||
let fileLength = 0
|
||||
let isUploading = false
|
||||
let uploadMode = 'direct'
|
||||
const progressSection = document.getElementById('progressSection')
|
||||
const uploadBtn = document.getElementById('uploadBtn')
|
||||
const fileInput = document.querySelector('input[type="file"]')
|
||||
const modeInfo = document.getElementById('modeInfo')
|
||||
|
||||
document.querySelectorAll('input[name="uploadMode"]').forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
uploadMode = this.value
|
||||
if (uploadMode === 'direct') {
|
||||
modeInfo.textContent = '支持大文件上传,文件将直接传输到云存储服务器'
|
||||
modeInfo.className = 'info-text'
|
||||
} else {
|
||||
modeInfo.textContent = '文件将通过服务器转发,服务器需支持大文件上传(Vercel 限制 4.5MB)'
|
||||
modeInfo.className = 'warning-text'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function main() {
|
||||
document
|
||||
.querySelector('input[type="file"]')
|
||||
.addEventListener('change', function (e) {
|
||||
fileInput.addEventListener('change', function (e) {
|
||||
const files = this.files
|
||||
if (files.length === 0) return
|
||||
if (isUploading) return
|
||||
|
||||
fileLength = files.length
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
upload(files[i], i + 1)
|
||||
}
|
||||
uploadFilesSequentially(Array.from(files))
|
||||
this.value = ''
|
||||
})
|
||||
}
|
||||
main()
|
||||
|
||||
function upload(file, currentIndex) {
|
||||
var formData = new FormData()
|
||||
async function uploadFilesSequentially(files) {
|
||||
isUploading = true
|
||||
uploadBtn.classList.add('disabled')
|
||||
progressSection.classList.add('active')
|
||||
progressSection.innerHTML = ''
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
if (uploadMode === 'direct') {
|
||||
await uploadFileDirect(files[i], i + 1, files.length)
|
||||
} else {
|
||||
await uploadFileProxy(files[i], i + 1, files.length)
|
||||
}
|
||||
}
|
||||
|
||||
isUploading = false
|
||||
uploadBtn.classList.remove('disabled')
|
||||
app.getData()
|
||||
}
|
||||
|
||||
function createProgressItem(file, index, total) {
|
||||
const item = document.createElement('div')
|
||||
item.className = 'progress-item'
|
||||
item.id = `progress-${index}`
|
||||
item.innerHTML = `
|
||||
<div class="name">${file.name} (${formatSize(file.size)})</div>
|
||||
<div class="status">准备中...</div>
|
||||
<div class="progress-bar"><div class="fill"></div></div>
|
||||
`
|
||||
progressSection.appendChild(item)
|
||||
return item
|
||||
}
|
||||
|
||||
function updateProgress(index, status, percent, isError = false) {
|
||||
const item = document.getElementById(`progress-${index}`)
|
||||
if (!item) return
|
||||
item.querySelector('.status').textContent = status
|
||||
item.querySelector('.fill').style.width = `${percent}%`
|
||||
if (isError) {
|
||||
item.classList.add('error')
|
||||
} else if (percent >= 100) {
|
||||
item.classList.add('success')
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
async function uploadFileProxy(file, index, total) {
|
||||
createProgressItem(file, index, total)
|
||||
|
||||
try {
|
||||
updateProgress(index, '上传中...', 10)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('songFile', file)
|
||||
|
||||
axios({
|
||||
await axios({
|
||||
method: 'post',
|
||||
url: `/cloud?time=${Date.now()}&cookie=${localStorage.getItem('cookie')}`,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
data: formData,
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 90) + 10
|
||||
updateProgress(index, `上传中... ${Math.round(progressEvent.loaded / progressEvent.total * 100)}%`, Math.min(percent, 100))
|
||||
},
|
||||
timeout: 600000,
|
||||
})
|
||||
.then((res) => {
|
||||
console.log(`${file.name} 上传成功`)
|
||||
if (currentIndex >= fileLength) {
|
||||
console.log('所有文件上传完毕')
|
||||
}
|
||||
app.getData()
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
updateProgress(index, '上传完成!', 100)
|
||||
|
||||
} catch (err) {
|
||||
console.error(`${file.name} 上传失败:`, err)
|
||||
fileUpdateTime[file.name] = (fileUpdateTime[file.name] || 0) + 1
|
||||
if (fileUpdateTime[file.name] >= 4) {
|
||||
console.error(`文件 ${file.name} 上传失败次数过多,已停止重试`)
|
||||
return
|
||||
const errorMsg = err.response?.data?.msg || err.message || '未知错误'
|
||||
if (err.response?.status === 413 || errorMsg.includes('PAYLOAD_TOO_LARGE')) {
|
||||
updateProgress(index, '文件过大,请切换到客户端直传模式', 0, true)
|
||||
} else {
|
||||
console.error(`${file.name} 上传失败 ${fileUpdateTime[file.name]} 次,正在重试...`)
|
||||
upload(file, currentIndex)
|
||||
updateProgress(index, `上传失败: ${errorMsg}`, 0, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function calculateMD5(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunkSize = 2 * 1024 * 1024
|
||||
const chunks = Math.ceil(file.size / chunkSize)
|
||||
let currentChunk = 0
|
||||
const spark = new SparkMD5.ArrayBuffer()
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (e) => {
|
||||
spark.append(e.target.result)
|
||||
currentChunk++
|
||||
if (currentChunk < chunks) {
|
||||
loadNext()
|
||||
} else {
|
||||
resolve(spark.end())
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = () => reject(reader.error)
|
||||
|
||||
function loadNext() {
|
||||
const start = currentChunk * chunkSize
|
||||
const end = Math.min(start + chunkSize, file.size)
|
||||
reader.readAsArrayBuffer(file.slice(start, end))
|
||||
}
|
||||
|
||||
loadNext()
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadFileDirect(file, index, total) {
|
||||
createProgressItem(file, index, total)
|
||||
|
||||
try {
|
||||
updateProgress(index, '计算文件MD5...', 5)
|
||||
|
||||
const md5 = await calculateMD5(file)
|
||||
const fileSize = file.size
|
||||
const filename = file.name
|
||||
|
||||
updateProgress(index, '获取上传凭证...', 10)
|
||||
|
||||
const tokenRes = await axios({
|
||||
method: 'post',
|
||||
url: `/cloud/upload/token?time=${Date.now()}&cookie=${localStorage.getItem('cookie')}`,
|
||||
data: {
|
||||
md5: md5,
|
||||
fileSize: fileSize,
|
||||
filename: filename,
|
||||
},
|
||||
})
|
||||
|
||||
if (tokenRes.data.code !== 200) {
|
||||
throw new Error(tokenRes.data.msg || '获取上传凭证失败')
|
||||
}
|
||||
|
||||
const tokenData = tokenRes.data.data
|
||||
|
||||
if (!tokenData.needUpload) {
|
||||
updateProgress(index, '文件已存在,直接导入云盘...', 80)
|
||||
await completeUpload(tokenData, file.name)
|
||||
updateProgress(index, '上传完成!', 100)
|
||||
return
|
||||
}
|
||||
|
||||
updateProgress(index, '开始上传到云存储...', 15)
|
||||
|
||||
await axios({
|
||||
method: 'post',
|
||||
url: tokenData.uploadUrl,
|
||||
headers: {
|
||||
'x-nos-token': tokenData.uploadToken,
|
||||
'Content-MD5': md5,
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Content-Length': String(fileSize),
|
||||
},
|
||||
data: file,
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 70) + 15
|
||||
updateProgress(index, `上传中... ${Math.round(progressEvent.loaded / progressEvent.total * 100)}%`, Math.min(percent, 85))
|
||||
},
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
timeout: 600000,
|
||||
})
|
||||
|
||||
updateProgress(index, '上传完成,正在导入云盘...', 90)
|
||||
|
||||
await completeUpload(tokenData, file.name)
|
||||
|
||||
updateProgress(index, '上传完成!', 100)
|
||||
|
||||
} catch (err) {
|
||||
console.error(`${file.name} 上传失败:`, err)
|
||||
const errorMsg = err.response?.data?.msg || err.message || '未知错误'
|
||||
updateProgress(index, `上传失败: ${errorMsg}`, 0, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function completeUpload(tokenData, filename) {
|
||||
const songName = filename.replace(/\.[^.]+$/, '')
|
||||
|
||||
const completeRes = await axios({
|
||||
method: 'post',
|
||||
url: `/cloud/upload/complete?time=${Date.now()}&cookie=${localStorage.getItem('cookie')}`,
|
||||
data: {
|
||||
songId: tokenData.songId,
|
||||
resourceId: tokenData.resourceId,
|
||||
md5: tokenData.md5,
|
||||
filename: filename,
|
||||
song: songName,
|
||||
artist: '未知艺术家',
|
||||
album: '未知专辑',
|
||||
},
|
||||
})
|
||||
|
||||
if (completeRes.data.code !== 200) {
|
||||
throw new Error(completeRes.data.msg || '导入云盘失败')
|
||||
}
|
||||
|
||||
return completeRes.data
|
||||
}
|
||||
</script>
|
||||
<script src="https://fastly.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -2768,7 +2768,7 @@ type : 地区
|
||||
|
||||
参考: https://github.com/neteasecloudmusicapienhanced/api-enhanced/blob/main/public/cloud.html
|
||||
|
||||
访问地址: http://localhost:3000/cloud.html)
|
||||
访问地址: http://localhost:3000/cloud.html
|
||||
|
||||
支持命令行调用,参考 module_example 目录下`song_upload.js`
|
||||
|
||||
@ -2776,6 +2776,70 @@ type : 地区
|
||||
|
||||
**调用例子 :** `/cloud`
|
||||
|
||||
#### 上传模式说明
|
||||
|
||||
云盘上传支持两种模式:
|
||||
|
||||
**1. 后端代理模式 (默认)**
|
||||
|
||||
文件通过服务器转发到云存储,调用简单,但受服务器限制:
|
||||
- Vercel Serverless Functions 限制请求体大小为 4.5MB
|
||||
- 自建服务器需配置足够大的请求体限制
|
||||
|
||||
**2. 客户端直传模式 (推荐用于 Vercel)**
|
||||
|
||||
文件直接从客户端上传到云存储服务器,绕过服务器限制:
|
||||
- 支持大文件上传
|
||||
- 适合 Vercel、Netlify 等有请求体限制的平台
|
||||
- 需要前端配合实现
|
||||
|
||||
#### 客户端直传相关接口
|
||||
|
||||
**获取上传凭证**
|
||||
|
||||
**接口地址 :** `/cloud/upload/token`
|
||||
|
||||
**必选参数 :**
|
||||
- `md5`: 文件 MD5 值
|
||||
- `fileSize`: 文件大小(字节)
|
||||
- `filename`: 文件名
|
||||
|
||||
**返回数据 :**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"needUpload": true,
|
||||
"songId": "...",
|
||||
"uploadToken": "...",
|
||||
"uploadUrl": "...",
|
||||
"resourceId": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**完成上传导入**
|
||||
|
||||
**接口地址 :** `/cloud/upload/complete`
|
||||
|
||||
**必选参数 :**
|
||||
- `songId`: 歌曲 ID
|
||||
- `resourceId`: 资源 ID
|
||||
- `md5`: 文件 MD5
|
||||
- `filename`: 文件名
|
||||
|
||||
**可选参数 :**
|
||||
- `song`: 歌曲名
|
||||
- `artist`: 艺术家
|
||||
- `album`: 专辑名
|
||||
|
||||
#### 客户端直传流程
|
||||
|
||||
1. 客户端计算文件 MD5
|
||||
2. 调用 `/cloud/upload/token` 获取上传凭证
|
||||
3. 如果 `needUpload` 为 true,直接 PUT 文件到 `uploadUrl`
|
||||
4. 调用 `/cloud/upload/complete` 完成导入
|
||||
|
||||
### 云盘歌曲信息匹配纠正
|
||||
|
||||
说明 : 登录后调用此接口,可对云盘歌曲信息匹配纠正,如需取消匹配,asid 需要传 0
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user