mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced.git
synced 2026-03-21 11:03:15 +00:00
Compare commits
17 Commits
2047bdfa65
...
b027aca40a
| Author | SHA1 | Date | |
|---|---|---|---|
| b027aca40a | |||
|
|
b1729f19aa | ||
|
|
755489546b | ||
|
|
92df6e13a0 | ||
|
|
1ad8ab088f | ||
|
|
a09f126ab2 | ||
|
|
872bae1b43 | ||
|
|
83c527af01 | ||
|
|
84efe5d758 | ||
|
|
cfa475bf94 | ||
|
|
4087bafd7d | ||
|
|
8951e32a0e | ||
|
|
33ccc83615 | ||
|
|
2d6173b2aa | ||
|
|
ba7d1a8574 | ||
|
|
26d55255e0 | ||
|
|
4489f10f63 |
22
README.MD
22
README.MD
@ -100,15 +100,15 @@ $ sudo docker run -d -p 3000:3000 ncm-api
|
|||||||
|
|
||||||
## 3. 环境变量
|
## 3. 环境变量
|
||||||
|
|
||||||
| 变量名 | 默认值 | 说明 |
|
| 变量名 | 默认值 | 说明 |
|
||||||
| -------------------------- | ------------------------------------ | ------------------------------------------------------------------------------ |
|
|----------------------------|--------------------------------------|----------------------------------------------------|
|
||||||
| **CORS_ALLOW_ORIGIN** | `*` | 允许跨域请求的域名。若需要限制,请指定具体域名(例如 `https://example.com`)。 |
|
| **CORS_ALLOW_ORIGIN** | `*` | 允许跨域请求的域名。若需要限制,请指定具体域名(例如 `https://example.com`)。 |
|
||||||
| **ENABLE_PROXY** | `false` | 是否启用反向代理功能。 |
|
| **ENABLE_PROXY** | `false` | 是否启用反向代理功能。 |
|
||||||
| **PROXY_URL** | `https://your-proxy-url.com/?proxy=` | 代理服务地址。仅当 `ENABLE_PROXY=true` 时生效。 |
|
| **PROXY_URL** | `https://your-proxy-url.com/?proxy=` | 代理服务地址。仅当 `ENABLE_PROXY=true` 时生效。 |
|
||||||
| **ENABLE_GENERAL_UNBLOCK** | `true` | 是否启用全局解灰(推荐开启)。开启后所有歌曲都尝试自动解锁。 |
|
| **ENABLE_GENERAL_UNBLOCK** | `true` | 是否启用全局解灰(推荐开启)。开启后所有歌曲都尝试自动解锁。 |
|
||||||
| **ENABLE_FLAC** | `true` | 是否启用无损音质(FLAC)。 |
|
| **ENABLE_FLAC** | `true` | 是否启用无损音质(FLAC)。 |
|
||||||
| **SELECT_MAX_BR** | `false` | 启用无损音质时,是否选择最高码率音质。 |
|
| **SELECT_MAX_BR** | `false` | 启用无损音质时,是否选择最高码率音质。 |
|
||||||
| **FOLLOW_SOURCE_ORDER** | `true` | 是否严格按照音源列表顺序进行匹配。 |
|
| **FOLLOW_SOURCE_ORDER** | `true` | 是否严格按照音源列表顺序进行匹配。 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -208,11 +208,11 @@ pnpm test
|
|||||||
|
|
||||||
### SDK 生态
|
### SDK 生态
|
||||||
|
|
||||||
| 语言 | 作者 | 地址 | 类型 |
|
| 语言 | 作者 | 地址 | 类型 |
|
||||||
| ------ | ------------------------------------------- | ---------------------------------------------------------------------------------------- | ------ |
|
|--------|---------------------------------------------|------------------------------------------------------------------------------------------|-----|
|
||||||
| Java | [JackuXL](https://github.com/JackuXL) | [NeteaseCloudMusicApi-SDK](https://github.com/JackuXL/NeteaseCloudMusicApi-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 | 第三方 |
|
| 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) | 第三方 |
|
| Python | [盧瞳](https://github.com/2061360308) | [NeteaseCloudMusic_PythonSDK](https://github.com/2061360308/NeteaseCloudMusic_PythonSDK) | 第三方 |
|
||||||
|
|
||||||
|
|
||||||
### 依赖此项目的优秀开源项目
|
### 依赖此项目的优秀开源项目
|
||||||
|
|||||||
@ -16,7 +16,7 @@ const compat = new FlatCompat({
|
|||||||
module.exports = defineConfig([
|
module.exports = defineConfig([
|
||||||
{
|
{
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2018,
|
ecmaVersion: 2020,
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
parser: 'babel-eslint',
|
parser: 'babel-eslint',
|
||||||
|
|||||||
261
module/cloud.js
261
module/cloud.js
@ -1,25 +1,26 @@
|
|||||||
const uploadPlugin = require('../plugins/songUpload')
|
const uploadPlugin = require('../plugins/songUpload')
|
||||||
const md5 = require('md5')
|
|
||||||
const createOption = require('../util/option.js')
|
const createOption = require('../util/option.js')
|
||||||
const logger = require('../util/logger.js')
|
const logger = require('../util/logger.js')
|
||||||
|
const {
|
||||||
|
isTempFile,
|
||||||
|
getFileSize,
|
||||||
|
getFileMd5,
|
||||||
|
cleanupTempFile,
|
||||||
|
getFileExtension,
|
||||||
|
sanitizeFilename,
|
||||||
|
} = require('../util/fileHelper')
|
||||||
|
|
||||||
let mm
|
let mm
|
||||||
module.exports = async (query, request) => {
|
module.exports = async (query, request) => {
|
||||||
mm = require('music-metadata')
|
mm = require('music-metadata')
|
||||||
let ext = 'mp3'
|
|
||||||
// if (query.songFile.name.indexOf('flac') > -1) {
|
|
||||||
// ext = 'flac'
|
|
||||||
// }
|
|
||||||
if (query.songFile.name.includes('.')) {
|
|
||||||
ext = query.songFile.name.split('.').pop()
|
|
||||||
}
|
|
||||||
query.songFile.name = Buffer.from(query.songFile.name, 'latin1').toString(
|
query.songFile.name = Buffer.from(query.songFile.name, 'latin1').toString(
|
||||||
'utf-8',
|
'utf-8',
|
||||||
)
|
)
|
||||||
const filename = query.songFile.name
|
const ext = getFileExtension(query.songFile.name)
|
||||||
.replace('.' + ext, '')
|
const filename = sanitizeFilename(query.songFile.name)
|
||||||
.replace(/\s/g, '')
|
|
||||||
.replace(/\./g, '_')
|
|
||||||
const bitrate = 999000
|
const bitrate = 999000
|
||||||
|
|
||||||
if (!query.songFile) {
|
if (!query.songFile) {
|
||||||
return Promise.reject({
|
return Promise.reject({
|
||||||
status: 500,
|
status: 500,
|
||||||
@ -29,119 +30,135 @@ module.exports = async (query, request) => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!query.songFile.md5) {
|
|
||||||
// 命令行上传没有md5和size信息,需要填充
|
const useTemp = isTempFile(query.songFile)
|
||||||
query.songFile.md5 = md5(query.songFile.data)
|
let fileSize = await getFileSize(query.songFile)
|
||||||
query.songFile.size = query.songFile.data.byteLength
|
let fileMd5 = await getFileMd5(query.songFile)
|
||||||
}
|
|
||||||
const res = await request(
|
query.songFile.md5 = fileMd5
|
||||||
`/api/cloud/upload/check`,
|
query.songFile.size = fileSize
|
||||||
{
|
|
||||||
bitrate: String(bitrate),
|
|
||||||
ext: '',
|
|
||||||
length: query.songFile.size,
|
|
||||||
md5: query.songFile.md5,
|
|
||||||
songId: '0',
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
createOption(query),
|
|
||||||
)
|
|
||||||
let artist = ''
|
|
||||||
let album = ''
|
|
||||||
let songName = ''
|
|
||||||
try {
|
try {
|
||||||
const metadata = await mm.parseBuffer(
|
const res = await request(
|
||||||
query.songFile.data,
|
`/api/cloud/upload/check`,
|
||||||
query.songFile.mimetype,
|
{
|
||||||
|
bitrate: String(bitrate),
|
||||||
|
ext: '',
|
||||||
|
length: fileSize,
|
||||||
|
md5: fileMd5,
|
||||||
|
songId: '0',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
createOption(query),
|
||||||
)
|
)
|
||||||
const info = metadata.common
|
|
||||||
|
|
||||||
if (info.title) {
|
let artist = ''
|
||||||
songName = info.title
|
let album = ''
|
||||||
}
|
let songName = ''
|
||||||
if (info.album) {
|
|
||||||
album = info.album
|
|
||||||
}
|
|
||||||
if (info.artist) {
|
|
||||||
artist = info.artist
|
|
||||||
}
|
|
||||||
// if (metadata.native.ID3v1) {
|
|
||||||
// metadata.native.ID3v1.forEach((item) => {
|
|
||||||
// // logger.info(item.id, item.value)
|
|
||||||
// if (item.id === 'title') {
|
|
||||||
// songName = item.value
|
|
||||||
// }
|
|
||||||
// if (item.id === 'artist') {
|
|
||||||
// artist = item.value
|
|
||||||
// }
|
|
||||||
// if (item.id === 'album') {
|
|
||||||
// album = item.value
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// // logger.info({
|
|
||||||
// // songName,
|
|
||||||
// // album,
|
|
||||||
// // songName,
|
|
||||||
// // })
|
|
||||||
// }
|
|
||||||
// logger.info({
|
|
||||||
// songName,
|
|
||||||
// album,
|
|
||||||
// songName,
|
|
||||||
// })
|
|
||||||
} catch (error) {
|
|
||||||
logger.info(error)
|
|
||||||
}
|
|
||||||
const tokenRes = await request(
|
|
||||||
`/api/nos/token/alloc`,
|
|
||||||
{
|
|
||||||
bucket: '',
|
|
||||||
ext: ext,
|
|
||||||
filename: filename,
|
|
||||||
local: false,
|
|
||||||
nos_product: 3,
|
|
||||||
type: 'audio',
|
|
||||||
md5: query.songFile.md5,
|
|
||||||
},
|
|
||||||
createOption(query),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (res.body.needUpload) {
|
try {
|
||||||
const uploadInfo = await uploadPlugin(query, request)
|
let metadata
|
||||||
// logger.info('uploadInfo', uploadInfo.body.result.resourceId)
|
if (useTemp) {
|
||||||
}
|
metadata = await mm.parseFile(query.songFile.tempFilePath)
|
||||||
// logger.info(tokenRes.body.result)
|
} else {
|
||||||
const res2 = await request(
|
metadata = await mm.parseBuffer(
|
||||||
`/api/upload/cloud/info/v2`,
|
query.songFile.data,
|
||||||
{
|
query.songFile.mimetype,
|
||||||
md5: query.songFile.md5,
|
)
|
||||||
songid: res.body.songId,
|
}
|
||||||
filename: query.songFile.name,
|
const info = metadata.common
|
||||||
song: songName || filename,
|
if (info.title) songName = info.title
|
||||||
album: album || '未知专辑',
|
if (info.album) album = info.album
|
||||||
artist: artist || '未知艺术家',
|
if (info.artist) artist = info.artist
|
||||||
bitrate: String(bitrate),
|
} catch (error) {
|
||||||
resourceId: tokenRes.body.result.resourceId,
|
logger.info('元数据解析错误:', error.message)
|
||||||
},
|
}
|
||||||
createOption(query),
|
|
||||||
)
|
const tokenRes = await request(
|
||||||
// logger.info({ res2, privateCloud: res2.body.privateCloud })
|
`/api/nos/token/alloc`,
|
||||||
// logger.info(res.body.songId, 'songid')
|
{
|
||||||
const res3 = await request(
|
bucket: '',
|
||||||
`/api/cloud/pub/v2`,
|
ext: ext,
|
||||||
{
|
filename: filename,
|
||||||
songid: res2.body.songId,
|
local: false,
|
||||||
},
|
nos_product: 3,
|
||||||
createOption(query),
|
type: 'audio',
|
||||||
)
|
md5: fileMd5,
|
||||||
// logger.info({ res3 })
|
},
|
||||||
return {
|
createOption(query),
|
||||||
status: 200,
|
)
|
||||||
body: {
|
|
||||||
...res.body,
|
if (!tokenRes.body.result || !tokenRes.body.result.resourceId) {
|
||||||
...res3.body,
|
logger.error('Token分配失败:', tokenRes.body)
|
||||||
// ...uploadInfo,
|
return Promise.reject({
|
||||||
},
|
status: 500,
|
||||||
cookie: res.cookie,
|
body: {
|
||||||
|
code: 500,
|
||||||
|
msg: '获取上传token失败',
|
||||||
|
detail: tokenRes.body,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.body.needUpload) {
|
||||||
|
logger.info('需要上传,开始上传流程...')
|
||||||
|
try {
|
||||||
|
const uploadInfo = await uploadPlugin(query, request)
|
||||||
|
logger.info('上传完成:', uploadInfo?.body?.result?.resourceId)
|
||||||
|
} catch (uploadError) {
|
||||||
|
logger.error('上传失败:', uploadError)
|
||||||
|
return Promise.reject(uploadError)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('文件已存在,跳过上传')
|
||||||
|
}
|
||||||
|
|
||||||
|
const res2 = await request(
|
||||||
|
`/api/upload/cloud/info/v2`,
|
||||||
|
{
|
||||||
|
md5: fileMd5,
|
||||||
|
songid: res.body.songId,
|
||||||
|
filename: query.songFile.name,
|
||||||
|
song: songName || filename,
|
||||||
|
album: album || '未知专辑',
|
||||||
|
artist: artist || '未知艺术家',
|
||||||
|
bitrate: String(bitrate),
|
||||||
|
resourceId: tokenRes.body.result.resourceId,
|
||||||
|
},
|
||||||
|
createOption(query),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (res2.body.code !== 200) {
|
||||||
|
logger.error('云盘信息上传失败:', res2.body)
|
||||||
|
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: {
|
||||||
|
...res.body,
|
||||||
|
...res3.body,
|
||||||
|
},
|
||||||
|
cookie: res.cookie,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (useTemp) {
|
||||||
|
await cleanupTempFile(query.songFile.tempFilePath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
module/cloud_upload_complete.js
Normal file
72
module/cloud_upload_complete.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
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 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
111
module/cloud_upload_token.js
Normal file
111
module/cloud_upload_token.js
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
const { default: axios } = require('axios')
|
||||||
|
const createOption = require('../util/option.js')
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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(/\//g, '%2F')}?offset=0&complete=true&version=1.0`,
|
||||||
|
bucket: bucket,
|
||||||
|
md5: md5,
|
||||||
|
fileSize: fileSize,
|
||||||
|
filename: filename,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cookie: checkRes.cookie,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,25 +1,26 @@
|
|||||||
const { default: axios } = require('axios')
|
const { default: axios } = require('axios')
|
||||||
|
const fs = require('fs')
|
||||||
var xml2js = require('xml2js')
|
var xml2js = require('xml2js')
|
||||||
|
|
||||||
const createOption = require('../util/option.js')
|
const createOption = require('../util/option.js')
|
||||||
var parser = new xml2js.Parser(/* options */)
|
const { getFileExtension, readFileChunk } = require('../util/fileHelper')
|
||||||
|
|
||||||
|
var parser = new xml2js.Parser()
|
||||||
|
|
||||||
function createDupkey() {
|
function createDupkey() {
|
||||||
// 格式:3b443c7c-a87f-468d-ba38-46d407aaf23a
|
|
||||||
var s = []
|
var s = []
|
||||||
var hexDigits = '0123456789abcdef'
|
var hexDigits = '0123456789abcdef'
|
||||||
for (var i = 0; i < 36; i++) {
|
for (var i = 0; i < 36; i++) {
|
||||||
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1)
|
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1)
|
||||||
}
|
}
|
||||||
s[14] = '4' // bits 12-15 of the time_hi_and_version field to 0010
|
s[14] = '4'
|
||||||
s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1) // bits 6-7 of the clock_seq_hi_and_reserved to 01
|
s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1)
|
||||||
s[8] = s[13] = s[18] = s[23] = '-'
|
s[8] = s[13] = s[18] = s[23] = '-'
|
||||||
return s.join('')
|
return s.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = async (query, request) => {
|
module.exports = async (query, request) => {
|
||||||
let ext = 'mp3'
|
const ext = getFileExtension(query.songFile.name)
|
||||||
if (query.songFile.name.indexOf('flac') > -1) {
|
|
||||||
ext = 'flac'
|
|
||||||
}
|
|
||||||
const filename =
|
const filename =
|
||||||
query.songName ||
|
query.songName ||
|
||||||
query.songFile.name
|
query.songFile.name
|
||||||
@ -50,43 +51,58 @@ module.exports = async (query, request) => {
|
|||||||
createOption(query, 'weapi'),
|
createOption(query, 'weapi'),
|
||||||
)
|
)
|
||||||
|
|
||||||
const objectKey = tokenRes.body.result.objectKey.replace('/', '%2F')
|
const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F')
|
||||||
const docId = tokenRes.body.result.docId
|
const docId = tokenRes.body.result.docId
|
||||||
const res = await axios({
|
const res = await axios({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`,
|
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`,
|
||||||
headers: {
|
headers: {
|
||||||
'x-nos-token': tokenRes.body.result.token,
|
'x-nos-token': tokenRes.body.result.token,
|
||||||
'X-Nos-Meta-Content-Type': 'audio/mpeg',
|
'X-Nos-Meta-Content-Type': query.songFile.mimetype || 'audio/mpeg',
|
||||||
},
|
},
|
||||||
data: null,
|
data: null,
|
||||||
})
|
})
|
||||||
// return xml
|
|
||||||
const res2 = await parser.parseStringPromise(res.data)
|
const res2 = await parser.parseStringPromise(res.data)
|
||||||
|
|
||||||
const fileSize = query.songFile.data.length
|
const useTempFile = !!query.songFile.tempFilePath
|
||||||
const blockSize = 10 * 1024 * 1024 // 10MB
|
let fileSize = query.songFile.size
|
||||||
|
|
||||||
|
if (useTempFile) {
|
||||||
|
const stats = await fs.promises.stat(query.songFile.tempFilePath)
|
||||||
|
fileSize = stats.size
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockSize = 10 * 1024 * 1024
|
||||||
let offset = 0
|
let offset = 0
|
||||||
let blockIndex = 1
|
let blockIndex = 1
|
||||||
|
|
||||||
let etags = []
|
let etags = []
|
||||||
|
|
||||||
while (offset < fileSize) {
|
while (offset < fileSize) {
|
||||||
const chunk = query.songFile.data.slice(
|
let chunk
|
||||||
offset,
|
if (useTempFile) {
|
||||||
Math.min(offset + blockSize, fileSize),
|
chunk = await readFileChunk(
|
||||||
)
|
query.songFile.tempFilePath,
|
||||||
|
offset,
|
||||||
|
Math.min(blockSize, fileSize - offset),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
chunk = query.songFile.data.slice(
|
||||||
|
offset,
|
||||||
|
Math.min(offset + blockSize, fileSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const res3 = await axios({
|
const res3 = await axios({
|
||||||
method: 'put',
|
method: 'put',
|
||||||
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
|
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
|
||||||
headers: {
|
headers: {
|
||||||
'x-nos-token': tokenRes.body.result.token,
|
'x-nos-token': tokenRes.body.result.token,
|
||||||
'Content-Type': 'audio/mpeg',
|
'Content-Type': query.songFile.mimetype || 'audio/mpeg',
|
||||||
},
|
},
|
||||||
data: chunk,
|
data: chunk,
|
||||||
})
|
})
|
||||||
// get etag
|
|
||||||
const etag = res3.headers.etag
|
const etag = res3.headers.etag
|
||||||
etags.push(etag)
|
etags.push(etag)
|
||||||
offset += blockSize
|
offset += blockSize
|
||||||
@ -101,19 +117,17 @@ module.exports = async (query, request) => {
|
|||||||
}
|
}
|
||||||
completeStr += '</CompleteMultipartUpload>'
|
completeStr += '</CompleteMultipartUpload>'
|
||||||
|
|
||||||
// 文件处理
|
|
||||||
await axios({
|
await axios({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
|
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/plain;charset=UTF-8',
|
'Content-Type': 'text/plain;charset=UTF-8',
|
||||||
'X-Nos-Meta-Content-Type': 'audio/mpeg',
|
'X-Nos-Meta-Content-Type': query.songFile.mimetype || 'audio/mpeg',
|
||||||
'x-nos-token': tokenRes.body.result.token,
|
'x-nos-token': tokenRes.body.result.token,
|
||||||
},
|
},
|
||||||
data: completeStr,
|
data: completeStr,
|
||||||
})
|
})
|
||||||
|
|
||||||
// preCheck
|
|
||||||
await request(
|
await request(
|
||||||
`/api/voice/workbench/voice/batch/upload/preCheck`,
|
`/api/voice/workbench/voice/batch/upload/preCheck`,
|
||||||
{
|
{
|
||||||
|
|||||||
32
package.json
32
package.json
@ -20,7 +20,6 @@
|
|||||||
"node_modules/axios",
|
"node_modules/axios",
|
||||||
"node_modules/express",
|
"node_modules/express",
|
||||||
"node_modules/express-fileupload",
|
"node_modules/express-fileupload",
|
||||||
"node_modules/md5",
|
|
||||||
"node_modules/music-metadata",
|
"node_modules/music-metadata",
|
||||||
"node_modules/pac-proxy-agent",
|
"node_modules/pac-proxy-agent",
|
||||||
"node_modules/qrcode",
|
"node_modules/qrcode",
|
||||||
@ -66,17 +65,12 @@
|
|||||||
"data"
|
"data"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.2.2",
|
|
||||||
"axios": "^1.13.5",
|
|
||||||
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.2.2",
|
"@neteasecloudmusicapienhanced/unblockmusic-utils": "^0.2.2",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dotenv": "^17.2.4",
|
"dotenv": "^17.2.4",
|
||||||
"dotenv": "^17.2.4",
|
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-fileupload": "^1.5.2",
|
"express-fileupload": "^1.5.2",
|
||||||
"md5": "^2.3.0",
|
|
||||||
"music-metadata": "^11.12.0",
|
|
||||||
"music-metadata": "^11.12.0",
|
"music-metadata": "^11.12.0",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
"pac-proxy-agent": "^7.2.0",
|
"pac-proxy-agent": "^7.2.0",
|
||||||
@ -93,21 +87,21 @@
|
|||||||
"@types/express-fileupload": "^1.5.1",
|
"@types/express-fileupload": "^1.5.1",
|
||||||
"@types/mocha": "^10.0.10",
|
"@types/mocha": "^10.0.10",
|
||||||
"@types/node": "25.0.9",
|
"@types/node": "25.0.9",
|
||||||
"@typescript-eslint/eslint-plugin": "8.46.3",
|
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||||
"@typescript-eslint/parser": "8.53.0",
|
"@typescript-eslint/parser": "^8.53.0",
|
||||||
"eslint": "9.39.0",
|
"eslint": "^9.39.0",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-html": "8.1.3",
|
"eslint-plugin-html": "^8.1.3",
|
||||||
"eslint-plugin-prettier": "5.5.5",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"husky": "9.1.7",
|
"husky": "^9.1.7",
|
||||||
"intelli-espower-loader": "1.1.0",
|
"intelli-espower-loader": "^1.1.0",
|
||||||
"lint-staged": "16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"mocha": "11.7.5",
|
"mocha": "^11.7.5",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
"pkg": "^5.8.1",
|
"pkg": "^5.8.1",
|
||||||
"power-assert": "1.6.1",
|
"power-assert": "^1.6.1",
|
||||||
"prettier": "3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"typescript": "5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,17 @@
|
|||||||
const { default: axios } = require('axios')
|
const { default: axios } = require('axios')
|
||||||
const createOption = require('../util/option.js')
|
const createOption = require('../util/option.js')
|
||||||
const logger = require('../util/logger.js')
|
const logger = require('../util/logger.js')
|
||||||
|
const {
|
||||||
|
getUploadData,
|
||||||
|
getFileExtension,
|
||||||
|
sanitizeFilename,
|
||||||
|
} = require('../util/fileHelper')
|
||||||
|
|
||||||
module.exports = async (query, request) => {
|
module.exports = async (query, request) => {
|
||||||
let ext = 'mp3'
|
const ext = getFileExtension(query.songFile.name)
|
||||||
// if (query.songFile.name.indexOf('flac') > -1) {
|
const filename = sanitizeFilename(query.songFile.name)
|
||||||
// ext = 'flac'
|
|
||||||
// }
|
|
||||||
if (query.songFile.name.includes('.')) {
|
|
||||||
ext = query.songFile.name.split('.').pop()
|
|
||||||
}
|
|
||||||
const filename = query.songFile.name
|
|
||||||
.replace('.' + ext, '')
|
|
||||||
.replace(/\s/g, '')
|
|
||||||
.replace(/\./g, '_')
|
|
||||||
const bucket = 'jd-musicrep-privatecloud-audio-public'
|
const bucket = 'jd-musicrep-privatecloud-audio-public'
|
||||||
// 获取key和token
|
|
||||||
const tokenRes = await request(
|
const tokenRes = await request(
|
||||||
`/api/nos/token/alloc`,
|
`/api/nos/token/alloc`,
|
||||||
{
|
{
|
||||||
@ -29,31 +26,82 @@ module.exports = async (query, request) => {
|
|||||||
createOption(query, 'weapi'),
|
createOption(query, 'weapi'),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 上传
|
if (!tokenRes.body.result || !tokenRes.body.result.objectKey) {
|
||||||
const objectKey = tokenRes.body.result.objectKey.replace('/', '%2F')
|
logger.error('Token分配失败:', tokenRes.body)
|
||||||
|
throw {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
code: 500,
|
||||||
|
msg: '获取上传token失败',
|
||||||
|
detail: tokenRes.body,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F')
|
||||||
|
let lbs
|
||||||
try {
|
try {
|
||||||
const lbs = (
|
lbs = (
|
||||||
await axios({
|
await axios({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
url: `https://wanproxy.127.net/lbs?version=1.0&bucketname=${bucket}`,
|
url: `https://wanproxy.127.net/lbs?version=1.0&bucketname=${bucket}`,
|
||||||
|
timeout: 10000,
|
||||||
})
|
})
|
||||||
).data
|
).data
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('LBS获取失败:', error.message)
|
||||||
|
throw {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
code: 500,
|
||||||
|
msg: '获取上传服务器地址失败',
|
||||||
|
detail: error.message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lbs || !lbs.upload || !lbs.upload[0]) {
|
||||||
|
logger.error('无效的LBS响应:', lbs)
|
||||||
|
throw {
|
||||||
|
status: 500,
|
||||||
|
body: {
|
||||||
|
code: 500,
|
||||||
|
msg: '获取上传服务器地址无效',
|
||||||
|
detail: lbs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await axios({
|
await axios({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
url: `${lbs.upload[0]}/${bucket}/${objectKey}?offset=0&complete=true&version=1.0`,
|
url: `${lbs.upload[0]}/${bucket}/${objectKey}?offset=0&complete=true&version=1.0`,
|
||||||
headers: {
|
headers: {
|
||||||
'x-nos-token': tokenRes.body.result.token,
|
'x-nos-token': tokenRes.body.result.token,
|
||||||
'Content-MD5': query.songFile.md5,
|
'Content-MD5': query.songFile.md5,
|
||||||
'Content-Type': 'audio/mpeg',
|
'Content-Type': query.songFile.mimetype || 'audio/mpeg',
|
||||||
'Content-Length': String(query.songFile.size),
|
'Content-Length': String(query.songFile.size),
|
||||||
},
|
},
|
||||||
data: query.songFile.data,
|
data: getUploadData(query.songFile),
|
||||||
maxContentLength: Infinity,
|
maxContentLength: Infinity,
|
||||||
maxBodyLength: Infinity,
|
maxBodyLength: Infinity,
|
||||||
|
timeout: 300000,
|
||||||
})
|
})
|
||||||
|
logger.info('上传成功:', filename)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.info('error', error.response)
|
logger.error('上传失败:', {
|
||||||
throw error.response
|
status: error.response?.status,
|
||||||
|
data: error.response?.data,
|
||||||
|
message: error.message,
|
||||||
|
})
|
||||||
|
throw {
|
||||||
|
status: error.response?.status || 500,
|
||||||
|
body: {
|
||||||
|
code: error.response?.status || 500,
|
||||||
|
msg: '文件上传失败',
|
||||||
|
detail: error.response?.data || error.message,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...tokenRes,
|
...tokenRes,
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
const { default: axios } = require('axios')
|
const { default: axios } = require('axios')
|
||||||
const createOption = require('../util/option.js')
|
const createOption = require('../util/option.js')
|
||||||
|
const { getUploadData } = require('../util/fileHelper')
|
||||||
|
|
||||||
module.exports = async (query, request) => {
|
module.exports = async (query, request) => {
|
||||||
const data = {
|
const data = {
|
||||||
bucket: 'yyimgs',
|
bucket: 'yyimgs',
|
||||||
@ -10,27 +12,23 @@ module.exports = async (query, request) => {
|
|||||||
return_body: `{"code":200,"size":"$(ObjectSize)"}`,
|
return_body: `{"code":200,"size":"$(ObjectSize)"}`,
|
||||||
type: 'other',
|
type: 'other',
|
||||||
}
|
}
|
||||||
// 获取key和token
|
|
||||||
const res = await request(
|
const res = await request(
|
||||||
`/api/nos/token/alloc`,
|
`/api/nos/token/alloc`,
|
||||||
data,
|
data,
|
||||||
createOption(query, 'weapi'),
|
createOption(query, 'weapi'),
|
||||||
)
|
)
|
||||||
// 上传图片
|
|
||||||
const res2 = await axios({
|
const res2 = await axios({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
url: `https://nosup-hz1.127.net/yyimgs/${res.body.result.objectKey}?offset=0&complete=true&version=1.0`,
|
url: `https://nosup-hz1.127.net/yyimgs/${res.body.result.objectKey}?offset=0&complete=true&version=1.0`,
|
||||||
headers: {
|
headers: {
|
||||||
'x-nos-token': res.body.result.token,
|
'x-nos-token': res.body.result.token,
|
||||||
'Content-Type': 'image/jpeg',
|
'Content-Type': query.imgFile.mimetype || 'image/jpeg',
|
||||||
},
|
},
|
||||||
data: query.imgFile.data,
|
data: getUploadData(query.imgFile),
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// ...res.body.result,
|
|
||||||
// ...res2.data,
|
|
||||||
// ...res3.body,
|
|
||||||
url_pre: 'https://p1.music.126.net/' + res.body.result.objectKey,
|
url_pre: 'https://p1.music.126.net/' + res.body.result.objectKey,
|
||||||
imgId: res.body.result.docId,
|
imgId: res.body.result.docId,
|
||||||
}
|
}
|
||||||
|
|||||||
57
pnpm-lock.yaml
generated
57
pnpm-lock.yaml
generated
@ -26,9 +26,6 @@ importers:
|
|||||||
express-fileupload:
|
express-fileupload:
|
||||||
specifier: ^1.5.2
|
specifier: ^1.5.2
|
||||||
version: 1.5.2
|
version: 1.5.2
|
||||||
md5:
|
|
||||||
specifier: ^2.3.0
|
|
||||||
version: 2.3.0
|
|
||||||
music-metadata:
|
music-metadata:
|
||||||
specifier: ^11.12.0
|
specifier: ^11.12.0
|
||||||
version: 11.12.0
|
version: 11.12.0
|
||||||
@ -73,37 +70,37 @@ importers:
|
|||||||
specifier: 25.0.9
|
specifier: 25.0.9
|
||||||
version: 25.0.9
|
version: 25.0.9
|
||||||
'@typescript-eslint/eslint-plugin':
|
'@typescript-eslint/eslint-plugin':
|
||||||
specifier: 8.46.3
|
specifier: ^8.46.3
|
||||||
version: 8.46.3(@typescript-eslint/parser@8.53.0(eslint@9.39.0)(typescript@5.9.3))(eslint@9.39.0)(typescript@5.9.3)
|
version: 8.46.3(@typescript-eslint/parser@8.53.0(eslint@9.39.0)(typescript@5.9.3))(eslint@9.39.0)(typescript@5.9.3)
|
||||||
'@typescript-eslint/parser':
|
'@typescript-eslint/parser':
|
||||||
specifier: 8.53.0
|
specifier: ^8.53.0
|
||||||
version: 8.53.0(eslint@9.39.0)(typescript@5.9.3)
|
version: 8.53.0(eslint@9.39.0)(typescript@5.9.3)
|
||||||
eslint:
|
eslint:
|
||||||
specifier: 9.39.0
|
specifier: ^9.39.0
|
||||||
version: 9.39.0
|
version: 9.39.0
|
||||||
eslint-config-prettier:
|
eslint-config-prettier:
|
||||||
specifier: 10.1.8
|
specifier: ^10.1.8
|
||||||
version: 10.1.8(eslint@9.39.0)
|
version: 10.1.8(eslint@9.39.0)
|
||||||
eslint-plugin-html:
|
eslint-plugin-html:
|
||||||
specifier: 8.1.3
|
specifier: ^8.1.3
|
||||||
version: 8.1.3
|
version: 8.1.3
|
||||||
eslint-plugin-prettier:
|
eslint-plugin-prettier:
|
||||||
specifier: 5.5.5
|
specifier: ^5.5.5
|
||||||
version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.0))(eslint@9.39.0)(prettier@3.7.4)
|
version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.0))(eslint@9.39.0)(prettier@3.7.4)
|
||||||
globals:
|
globals:
|
||||||
specifier: ^16.5.0
|
specifier: ^16.5.0
|
||||||
version: 16.5.0
|
version: 16.5.0
|
||||||
husky:
|
husky:
|
||||||
specifier: 9.1.7
|
specifier: ^9.1.7
|
||||||
version: 9.1.7
|
version: 9.1.7
|
||||||
intelli-espower-loader:
|
intelli-espower-loader:
|
||||||
specifier: 1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
lint-staged:
|
lint-staged:
|
||||||
specifier: 16.2.7
|
specifier: ^16.2.7
|
||||||
version: 16.2.7
|
version: 16.2.7
|
||||||
mocha:
|
mocha:
|
||||||
specifier: 11.7.5
|
specifier: ^11.7.5
|
||||||
version: 11.7.5
|
version: 11.7.5
|
||||||
nodemon:
|
nodemon:
|
||||||
specifier: ^3.1.11
|
specifier: ^3.1.11
|
||||||
@ -112,13 +109,13 @@ importers:
|
|||||||
specifier: ^5.8.1
|
specifier: ^5.8.1
|
||||||
version: 5.8.1
|
version: 5.8.1
|
||||||
power-assert:
|
power-assert:
|
||||||
specifier: 1.6.1
|
specifier: ^1.6.1
|
||||||
version: 1.6.1
|
version: 1.6.1
|
||||||
prettier:
|
prettier:
|
||||||
specifier: 3.7.4
|
specifier: ^3.7.4
|
||||||
version: 3.7.4
|
version: 3.7.4
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 5.9.3
|
specifier: ^5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
@ -610,9 +607,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
charenc@0.0.2:
|
|
||||||
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
|
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
@ -710,9 +704,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
crypt@0.0.2:
|
|
||||||
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
|
|
||||||
|
|
||||||
crypto-js@4.2.0:
|
crypto-js@4.2.0:
|
||||||
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||||
|
|
||||||
@ -1448,9 +1439,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
is-buffer@1.1.6:
|
|
||||||
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
|
|
||||||
|
|
||||||
is-callable@1.2.7:
|
is-callable@1.2.7:
|
||||||
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -1651,9 +1639,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
md5@2.3.0:
|
|
||||||
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
|
|
||||||
|
|
||||||
media-typer@0.3.0:
|
media-typer@0.3.0:
|
||||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@ -2899,8 +2884,8 @@ snapshots:
|
|||||||
|
|
||||||
'@typescript-eslint/project-service@8.46.3(typescript@5.9.3)':
|
'@typescript-eslint/project-service@8.46.3(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/tsconfig-utils': 8.46.3(typescript@5.9.3)
|
'@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3)
|
||||||
'@typescript-eslint/types': 8.46.3
|
'@typescript-eslint/types': 8.53.0
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@ -3239,8 +3224,6 @@ snapshots:
|
|||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
supports-color: 7.2.0
|
supports-color: 7.2.0
|
||||||
|
|
||||||
charenc@0.0.2: {}
|
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
anymatch: 3.1.3
|
anymatch: 3.1.3
|
||||||
@ -3340,8 +3323,6 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
crypt@0.0.2: {}
|
|
||||||
|
|
||||||
crypto-js@4.2.0: {}
|
crypto-js@4.2.0: {}
|
||||||
|
|
||||||
d@1.0.2:
|
d@1.0.2:
|
||||||
@ -4277,8 +4258,6 @@ snapshots:
|
|||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
|
|
||||||
is-buffer@1.1.6: {}
|
|
||||||
|
|
||||||
is-callable@1.2.7: {}
|
is-callable@1.2.7: {}
|
||||||
|
|
||||||
is-core-module@2.16.1:
|
is-core-module@2.16.1:
|
||||||
@ -4478,12 +4457,6 @@ snapshots:
|
|||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
md5@2.3.0:
|
|
||||||
dependencies:
|
|
||||||
charenc: 0.0.2
|
|
||||||
crypt: 0.0.2
|
|
||||||
is-buffer: 1.1.6
|
|
||||||
|
|
||||||
media-typer@0.3.0: {}
|
media-typer@0.3.0: {}
|
||||||
|
|
||||||
media-typer@1.1.0: {}
|
media-typer@1.1.0: {}
|
||||||
|
|||||||
@ -47,6 +47,59 @@
|
|||||||
text-decoration: underline;
|
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 {
|
.upload-section {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
@ -72,6 +125,11 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-btn.disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.songs-list {
|
.songs-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
@ -99,6 +157,74 @@
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
color: #666;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@ -107,13 +233,36 @@
|
|||||||
<h1>云盘上传</h1>
|
<h1>云盘上传</h1>
|
||||||
<a href="/qrlogin-nocookie.html" class="login-link">还没登录?点击登录</a>
|
<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">
|
<div class="upload-section">
|
||||||
<label class="upload-btn">
|
<label class="upload-btn" id="uploadBtn">
|
||||||
选择文件(支持多选)
|
选择文件(支持多选)
|
||||||
<input id="file" type="file" multiple accept="audio/*" />
|
<input id="file" type="file" multiple accept="audio/*" />
|
||||||
</label>
|
</label>
|
||||||
|
<p class="info-text" id="modeInfo">支持大文件上传,文件将直接传输到云存储服务器</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="progressSection" class="progress-section"></div>
|
||||||
|
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div v-if="loading" class="loading">加载中...</div>
|
<div v-if="loading" class="loading">加载中...</div>
|
||||||
<ul v-else-if="songs.length > 0" class="songs-list">
|
<ul v-else-if="songs.length > 0" class="songs-list">
|
||||||
@ -127,6 +276,7 @@
|
|||||||
|
|
||||||
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
|
<script src="https://fastly.jsdelivr.net/npm/axios@0.26.1/dist/axios.min.js"></script>
|
||||||
<script src="https://fastly.jsdelivr.net/npm/vue@3"></script>
|
<script src="https://fastly.jsdelivr.net/npm/vue@3"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsmediatags/3.9.5/jsmediatags.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const app = Vue.createApp({
|
const app = Vue.createApp({
|
||||||
data() {
|
data() {
|
||||||
@ -157,55 +307,272 @@
|
|||||||
},
|
},
|
||||||
}).mount('#app')
|
}).mount('#app')
|
||||||
|
|
||||||
const fileUpdateTime = {}
|
let isUploading = false
|
||||||
let fileLength = 0
|
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() {
|
function main() {
|
||||||
document
|
fileInput.addEventListener('change', function (e) {
|
||||||
.querySelector('input[type="file"]')
|
const files = this.files
|
||||||
.addEventListener('change', function (e) {
|
if (files.length === 0) return
|
||||||
const files = this.files
|
if (isUploading) return
|
||||||
if (files.length === 0) return
|
|
||||||
|
|
||||||
fileLength = files.length
|
uploadFilesSequentially(Array.from(files))
|
||||||
for (let i = 0; i < files.length; i++) {
|
this.value = ''
|
||||||
upload(files[i], i + 1)
|
})
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
main()
|
main()
|
||||||
|
|
||||||
function upload(file, currentIndex) {
|
async function uploadFilesSequentially(files) {
|
||||||
var formData = new FormData()
|
isUploading = true
|
||||||
formData.append('songFile', file)
|
uploadBtn.classList.add('disabled')
|
||||||
|
progressSection.classList.add('active')
|
||||||
|
progressSection.innerHTML = ''
|
||||||
|
|
||||||
axios({
|
for (let i = 0; i < files.length; i++) {
|
||||||
method: 'post',
|
if (uploadMode === 'direct') {
|
||||||
url: `/cloud?time=${Date.now()}&cookie=${localStorage.getItem('cookie')}`,
|
await uploadFileDirect(files[i], i + 1, files.length)
|
||||||
headers: {
|
} else {
|
||||||
'Content-Type': 'multipart/form-data',
|
await uploadFileProxy(files[i], i + 1, files.length)
|
||||||
},
|
}
|
||||||
data: formData,
|
}
|
||||||
})
|
|
||||||
.then((res) => {
|
isUploading = false
|
||||||
console.log(`${file.name} 上传成功`)
|
uploadBtn.classList.remove('disabled')
|
||||||
if (currentIndex >= fileLength) {
|
app.getData()
|
||||||
console.log('所有文件上传完毕')
|
}
|
||||||
}
|
|
||||||
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)
|
||||||
|
|
||||||
|
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,
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
|
||||||
console.error(`${file.name} 上传失败:`, err)
|
updateProgress(index, '上传完成!', 100)
|
||||||
fileUpdateTime[file.name] = (fileUpdateTime[file.name] || 0) + 1
|
|
||||||
if (fileUpdateTime[file.name] >= 4) {
|
} catch (err) {
|
||||||
console.error(`文件 ${file.name} 上传失败次数过多,已停止重试`)
|
console.error(`${file.name} 上传失败:`, err)
|
||||||
return
|
const errorMsg = err.response?.data?.msg || err.message || '未知错误'
|
||||||
|
if (err.response?.status === 413 || errorMsg.includes('PAYLOAD_TOO_LARGE')) {
|
||||||
|
updateProgress(index, '文件过大,请切换到客户端直传模式', 0, true)
|
||||||
|
} else {
|
||||||
|
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 {
|
} else {
|
||||||
console.error(`${file.name} 上传失败 ${fileUpdateTime[file.name]} 次,正在重试...`)
|
resolve(spark.end())
|
||||||
upload(file, currentIndex)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 parseMediaTags(file) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
jsmediatags.read(file, {
|
||||||
|
onSuccess: function(tag) {
|
||||||
|
resolve({
|
||||||
|
title: tag.tags.title || null,
|
||||||
|
artist: tag.tags.artist || null,
|
||||||
|
album: tag.tags.album || null,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: function() {
|
||||||
|
resolve({ title: null, artist: null, album: null })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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, '解析音频元数据...', 8)
|
||||||
|
|
||||||
|
const mediaTags = await parseMediaTags(file)
|
||||||
|
|
||||||
|
updateProgress(index, '获取上传凭证...', 10)
|
||||||
|
|
||||||
|
const tokenRes = await axios({
|
||||||
|
method: 'post',
|
||||||
|
url: `/cloud/upload/token?time=${Date.now()}`,
|
||||||
|
data: {
|
||||||
|
cookie: localStorage.getItem('cookie'),
|
||||||
|
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, mediaTags)
|
||||||
|
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, mediaTags)
|
||||||
|
|
||||||
|
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, file, mediaTags = {}) {
|
||||||
|
const songName = mediaTags.title || file.name.replace(/\.[^.]+$/, '')
|
||||||
|
const artist = mediaTags.artist || '未知艺术家'
|
||||||
|
const album = mediaTags.album || '未知专辑'
|
||||||
|
|
||||||
|
const completeRes = await axios({
|
||||||
|
method: 'post',
|
||||||
|
url: `/cloud/upload/complete?time=${Date.now()}`,
|
||||||
|
data: {
|
||||||
|
cookie: localStorage.getItem('cookie'),
|
||||||
|
songId: tokenData.songId,
|
||||||
|
resourceId: tokenData.resourceId,
|
||||||
|
md5: tokenData.md5,
|
||||||
|
filename: file.name,
|
||||||
|
song: songName,
|
||||||
|
artist: artist,
|
||||||
|
album: album,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (completeRes.data.code !== 200) {
|
||||||
|
throw new Error(completeRes.data.msg || '导入云盘失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
return completeRes.data
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script src="https://fastly.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -2768,7 +2768,7 @@ type : 地区
|
|||||||
|
|
||||||
参考: https://github.com/neteasecloudmusicapienhanced/api-enhanced/blob/main/public/cloud.html
|
参考: 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`
|
支持命令行调用,参考 module_example 目录下`song_upload.js`
|
||||||
|
|
||||||
@ -2776,6 +2776,72 @@ type : 地区
|
|||||||
|
|
||||||
**调用例子 :** `/cloud`
|
**调用例子 :** `/cloud`
|
||||||
|
|
||||||
|
#### 上传模式说明
|
||||||
|
|
||||||
|
云盘上传支持两种模式:
|
||||||
|
|
||||||
|
**1. 后端代理模式 (默认)**
|
||||||
|
|
||||||
|
文件通过服务器转发到云存储,调用简单,但受服务器限制:
|
||||||
|
- Vercel Serverless Functions 限制请求体大小为 4.5MB
|
||||||
|
- 自建服务器需配置足够大的请求体限制
|
||||||
|
|
||||||
|
**2. 客户端直传模式 (推荐用于 Vercel)**
|
||||||
|
|
||||||
|
文件直接从客户端上传到云存储服务器,绕过服务器限制:
|
||||||
|
- 支持大文件上传
|
||||||
|
- 适合 Vercel、Netlify 等有请求体限制的平台
|
||||||
|
- 需要前端配合实现
|
||||||
|
|
||||||
|
#### 客户端直传相关接口
|
||||||
|
|
||||||
|
**获取上传凭证**
|
||||||
|
|
||||||
|
**接口地址 :** `/cloud/upload/token`
|
||||||
|
|
||||||
|
**必选参数 :**
|
||||||
|
- `cookie`: 网易云音乐 Cookie (在请求体中传递)
|
||||||
|
- `md5`: 文件 MD5 值
|
||||||
|
- `fileSize`: 文件大小(字节)
|
||||||
|
- `filename`: 文件名
|
||||||
|
|
||||||
|
**返回数据 :**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"needUpload": true,
|
||||||
|
"songId": "...",
|
||||||
|
"uploadToken": "...",
|
||||||
|
"uploadUrl": "...",
|
||||||
|
"resourceId": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**完成上传导入**
|
||||||
|
|
||||||
|
**接口地址 :** `/cloud/upload/complete`
|
||||||
|
|
||||||
|
**必选参数 :**
|
||||||
|
- `cookie`: 网易云音乐 Cookie (在请求体中传递)
|
||||||
|
- `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
|
说明 : 登录后调用此接口,可对云盘歌曲信息匹配纠正,如需取消匹配,asid 需要传 0
|
||||||
|
|||||||
21
server.js
21
server.js
@ -178,10 +178,25 @@ async function consturctServer(moduleDefs) {
|
|||||||
/**
|
/**
|
||||||
* Body Parser and File Upload
|
* Body Parser and File Upload
|
||||||
*/
|
*/
|
||||||
app.use(express.json({ limit: '50mb' }))
|
const MAX_UPLOAD_SIZE_MB = 500
|
||||||
app.use(express.urlencoded({ extended: false, limit: '50mb' }))
|
const MAX_UPLOAD_SIZE_BYTES = MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
app.use(fileUpload())
|
app.use(express.json({ limit: `${MAX_UPLOAD_SIZE_MB}mb` }))
|
||||||
|
app.use(
|
||||||
|
express.urlencoded({ extended: false, limit: `${MAX_UPLOAD_SIZE_MB}mb` }),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
fileUpload({
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_UPLOAD_SIZE_BYTES,
|
||||||
|
},
|
||||||
|
useTempFiles: true,
|
||||||
|
tempFileDir: require('os').tmpdir(),
|
||||||
|
abortOnLimit: true,
|
||||||
|
parseNested: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cache
|
* Cache
|
||||||
|
|||||||
88
util/fileHelper.js
Normal file
88
util/fileHelper.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const logger = require('./logger')
|
||||||
|
|
||||||
|
function isTempFile(file) {
|
||||||
|
return !!(file && file.tempFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFileSize(file) {
|
||||||
|
if (isTempFile(file)) {
|
||||||
|
const stats = await fs.promises.stat(file.tempFilePath)
|
||||||
|
return stats.size
|
||||||
|
}
|
||||||
|
return file.data ? file.data.byteLength : file.size || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFileMd5(file) {
|
||||||
|
if (file.md5) {
|
||||||
|
return file.md5
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTempFile(file)) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash('md5')
|
||||||
|
const stream = fs.createReadStream(file.tempFilePath)
|
||||||
|
stream.on('data', (chunk) => hash.update(chunk))
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex')))
|
||||||
|
stream.on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.data) {
|
||||||
|
return crypto.createHash('md5').update(file.data).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('无法计算文件MD5: 缺少文件数据')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUploadData(file) {
|
||||||
|
if (isTempFile(file)) {
|
||||||
|
return fs.createReadStream(file.tempFilePath)
|
||||||
|
}
|
||||||
|
return file.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTempFile(filePath) {
|
||||||
|
if (!filePath) return
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(filePath)
|
||||||
|
} catch (e) {
|
||||||
|
logger.info('临时文件清理失败:', e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFileChunk(filePath, offset, length) {
|
||||||
|
const fd = await fs.promises.open(filePath, 'r')
|
||||||
|
const buffer = Buffer.alloc(length)
|
||||||
|
await fd.read(buffer, 0, length, offset)
|
||||||
|
await fd.close()
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileExtension(filename) {
|
||||||
|
if (!filename) return 'mp3'
|
||||||
|
if (filename.includes('.')) {
|
||||||
|
return filename.split('.').pop().toLowerCase()
|
||||||
|
}
|
||||||
|
return 'mp3'
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFilename(filename) {
|
||||||
|
if (!filename) return 'unknown'
|
||||||
|
return filename
|
||||||
|
.replace(/\.[^.]+$/, '')
|
||||||
|
.replace(/\s/g, '')
|
||||||
|
.replace(/\./g, '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isTempFile,
|
||||||
|
getFileSize,
|
||||||
|
getFileMd5,
|
||||||
|
getUploadData,
|
||||||
|
cleanupTempFile,
|
||||||
|
readFileChunk,
|
||||||
|
getFileExtension,
|
||||||
|
sanitizeFilename,
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user