mirror of
https://github.com/NeteaseCloudMusicApiEnhanced/api-enhanced.git
synced 2026-08-12 17:10:37 +00:00
Compare commits
6 Commits
8f4873f2e2
...
a79a2d9ef6
| Author | SHA1 | Date | |
|---|---|---|---|
| a79a2d9ef6 | |||
|
|
9608da9b96 | ||
|
|
93b3e624d8 | ||
| 15f515c1ba | |||
| 16aa61f98e | |||
| a939003b92 |
31
.github/workflows/opencode.yml
vendored
31
.github/workflows/opencode.yml
vendored
@ -5,9 +5,16 @@ on:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
opencode:
|
||||
bot:
|
||||
if: |
|
||||
contains(github.event.comment.body, ' /oc') ||
|
||||
startsWith(github.event.comment.body, '/oc') ||
|
||||
@ -15,19 +22,33 @@ jobs:
|
||||
startsWith(github.event.comment.body, '/opencode')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: Generate bot token
|
||||
id: token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config --global user.name "takanashi-hoshino-agent[bot]"
|
||||
git config --global user.email "takanashi-hoshino-agent[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@latest
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
|
||||
with:
|
||||
model: opencode/deepseek-v4-flash-free
|
||||
use_github_token: true
|
||||
prompt: ${{ secrets.OPENCODE_PROMPT }}
|
||||
46
.github/workflows/pr-reviewer.yml
vendored
Normal file
46
.github/workflows/pr-reviewer.yml
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
name: opencode-review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
review:
|
||||
# 跳过 fork 来的 PR:GitHub 不会把 secrets 传给 fork PR 触发的工作流,
|
||||
# 跑了也必然失败,所以直接跳过避免噪音(平台限制,非配置问题)。
|
||||
if: github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate bot token
|
||||
id: token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config --global user.name "takanashi-hoshino-agent[bot]"
|
||||
git config --global user.email "takanashi-hoshino-agent[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Run opencode review
|
||||
uses: anomalyco/opencode/github@latest
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
# 用自定义 GitHub App 生成的 bot token,跟原 opencode.yml 一致,
|
||||
# 评论 PR / 提交都走 takanashi-hoshino-agent[bot] 的身份。
|
||||
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
|
||||
with:
|
||||
model: opencode/deepseek-v4-flash-free
|
||||
use_github_token: true
|
||||
prompt: ${{ secrets.OPENCODE_PROMPT }}
|
||||
16
interface.d.ts
vendored
16
interface.d.ts
vendored
@ -2802,6 +2802,22 @@ export function voice_upload(
|
||||
name: string
|
||||
data: string | Buffer
|
||||
}
|
||||
imgFile?: {
|
||||
name: string
|
||||
data: string | Buffer
|
||||
}
|
||||
voiceListId: string | number
|
||||
coverImgId?: string | number
|
||||
categoryId: string | number
|
||||
secondCategoryId: string | number
|
||||
description: string
|
||||
songName?: string
|
||||
privacy?: string | number
|
||||
publishTime?: string | number
|
||||
autoPublish?: string | number
|
||||
autoPublishText?: string
|
||||
orderNo?: string | number
|
||||
composedSongs?: string
|
||||
} & RequestBaseConfig,
|
||||
): Promise<Response>
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ const { default: axios } = require('axios')
|
||||
const fs = require('fs')
|
||||
var xml2js = require('xml2js')
|
||||
|
||||
const uploadPlugin = require('../plugins/upload')
|
||||
const createOption = require('../util/option.js')
|
||||
const { getFileExtension, readFileChunk } = require('../util/fileHelper')
|
||||
|
||||
@ -19,15 +20,7 @@ function createDupkey() {
|
||||
return s.join('')
|
||||
}
|
||||
|
||||
module.exports = async (query, request) => {
|
||||
const ext = getFileExtension(query.songFile.name)
|
||||
const filename =
|
||||
query.songName ||
|
||||
query.songFile.name
|
||||
.replace('.' + ext, '')
|
||||
.replace(/\s/g, '')
|
||||
.replace(/\./g, '_')
|
||||
|
||||
module.exports = async (query, request, dependencies = {}) => {
|
||||
if (!query.songFile) {
|
||||
return Promise.reject({
|
||||
status: 500,
|
||||
@ -38,6 +31,19 @@ module.exports = async (query, request) => {
|
||||
})
|
||||
}
|
||||
|
||||
const axiosRequest = dependencies.axios || axios
|
||||
const uploadImage = dependencies.uploadPlugin || uploadPlugin
|
||||
const ext = getFileExtension(query.songFile.name)
|
||||
const filename =
|
||||
query.songName ||
|
||||
query.songFile.name
|
||||
.replace('.' + ext, '')
|
||||
.replace(/\s/g, '')
|
||||
.replace(/\./g, '_')
|
||||
const coverImgId = query.imgFile
|
||||
? (await uploadImage(query, request)).imgId
|
||||
: query.coverImgId
|
||||
|
||||
const tokenRes = await request(
|
||||
`/api/nos/token/alloc`,
|
||||
{
|
||||
@ -53,7 +59,7 @@ module.exports = async (query, request) => {
|
||||
|
||||
const objectKey = tokenRes.body.result.objectKey.replace(/\//g, '%2F')
|
||||
const docId = tokenRes.body.result.docId
|
||||
const res = await axios({
|
||||
const res = await axiosRequest({
|
||||
method: 'post',
|
||||
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploads`,
|
||||
headers: {
|
||||
@ -94,7 +100,7 @@ module.exports = async (query, request) => {
|
||||
)
|
||||
}
|
||||
|
||||
const res3 = await axios({
|
||||
const res3 = await axiosRequest({
|
||||
method: 'put',
|
||||
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?partNumber=${blockIndex}&uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
|
||||
headers: {
|
||||
@ -117,7 +123,7 @@ module.exports = async (query, request) => {
|
||||
}
|
||||
completeStr += '</CompleteMultipartUpload>'
|
||||
|
||||
await axios({
|
||||
await axiosRequest({
|
||||
method: 'post',
|
||||
url: `https://ymusic.nos-hz.163yun.com/${objectKey}?uploadId=${res2.InitiateMultipartUploadResult.UploadId[0]}`,
|
||||
headers: {
|
||||
@ -128,29 +134,29 @@ module.exports = async (query, request) => {
|
||||
data: completeStr,
|
||||
})
|
||||
|
||||
await request(
|
||||
`/api/voice/workbench/voice/batch/upload/preCheck`,
|
||||
{
|
||||
dupkey: createDupkey(),
|
||||
voiceData: JSON.stringify([
|
||||
const voiceData = JSON.stringify([
|
||||
{
|
||||
name: filename,
|
||||
autoPublish: query.autoPublish == 1 ? true : false,
|
||||
autoPublishText: query.autoPublishText || '',
|
||||
description: query.description,
|
||||
voiceListId: query.voiceListId,
|
||||
coverImgId: query.coverImgId,
|
||||
coverImgId,
|
||||
dfsId: docId,
|
||||
categoryId: query.categoryId,
|
||||
secondCategoryId: query.secondCategoryId,
|
||||
composedSongs: query.composedSongs
|
||||
? query.composedSongs.split(',')
|
||||
: [],
|
||||
composedSongs: query.composedSongs ? query.composedSongs.split(',') : [],
|
||||
privacy: query.privacy == 1 ? true : false,
|
||||
publishTime: query.publishTime || 0,
|
||||
orderNo: query.orderNo || 1,
|
||||
},
|
||||
]),
|
||||
])
|
||||
|
||||
await request(
|
||||
`/api/voice/workbench/voice/batch/upload/preCheck`,
|
||||
{
|
||||
dupkey: createDupkey(),
|
||||
voiceData,
|
||||
},
|
||||
{
|
||||
...createOption(query),
|
||||
@ -163,25 +169,7 @@ module.exports = async (query, request) => {
|
||||
`/api/voice/workbench/voice/batch/upload/v2`,
|
||||
{
|
||||
dupkey: createDupkey(),
|
||||
voiceData: JSON.stringify([
|
||||
{
|
||||
name: filename,
|
||||
autoPublish: query.autoPublish == 1 ? true : false,
|
||||
autoPublishText: query.autoPublishText || '',
|
||||
description: query.description,
|
||||
voiceListId: query.voiceListId,
|
||||
coverImgId: query.coverImgId,
|
||||
dfsId: docId,
|
||||
categoryId: query.categoryId,
|
||||
secondCategoryId: query.secondCategoryId,
|
||||
composedSongs: query.composedSongs
|
||||
? query.composedSongs.split(',')
|
||||
: [],
|
||||
privacy: query.privacy == 1 ? true : false,
|
||||
publishTime: query.publishTime || 0,
|
||||
orderNo: query.orderNo || 1,
|
||||
},
|
||||
]),
|
||||
voiceData,
|
||||
},
|
||||
{
|
||||
...createOption(query),
|
||||
|
||||
@ -4371,27 +4371,33 @@ ONLINE 已发布
|
||||
|
||||
### 播客上传声音
|
||||
|
||||
说明: 可以上传声音到播客,例子在 `/public/voice_upload.html` 访问地址: <a href="/voice_upload.html" target="_blank">/voice_upload.html</a>
|
||||
说明: 登录后调用此接口,使用`'Content-Type': 'multipart/form-data'`上传声音文件 formData(name 为`songFile`),可通过 formData(name 为`imgFile`)同时上传声音封面。例子在 `/public/voice_upload.html` 访问地址: <a href="/voice_upload.html" target="_blank">/voice_upload.html</a>
|
||||
|
||||
**接口地址:** `/voice/upload`
|
||||
|
||||
**必选参数:**
|
||||
`voiceListId`: 播客 id
|
||||
|
||||
`coverImgId`: 播客封面
|
||||
`songFile`: 声音文件
|
||||
|
||||
`voiceListId`: 播客 id
|
||||
|
||||
`categoryId`: 分类 id
|
||||
|
||||
`secondCategoryId`:次级分类 id
|
||||
`secondCategoryId`: 次级分类 id
|
||||
|
||||
`description`: 声音介绍
|
||||
|
||||
**可选参数:**
|
||||
|
||||
`imgFile`: 声音封面图片文件,上传后会自动生成图片 id。与`coverImgId`同时传入时,优先使用`imgFile`
|
||||
|
||||
`coverImgId`: 已上传的声音封面图片 id,未传入`imgFile`时使用该值
|
||||
|
||||
`songName`: 声音名称
|
||||
|
||||
`privacy`: 设为隐私声音,播客如果是隐私博客,则必须设为 1
|
||||
`privacy`: 设为隐私声音,播客如果是隐私播客,则必须设为 1
|
||||
|
||||
`publishTime`:默认立即发布,定时发布的话需传入时间戳
|
||||
`publishTime`: 默认立即发布,定时发布的话需传入时间戳
|
||||
|
||||
`autoPublish`: 是否发布动态,是则传入 1
|
||||
|
||||
|
||||
@ -217,8 +217,13 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>选择文件</label>
|
||||
<input type="file" name="songFile" accept="audio/*" />
|
||||
<label for="songFile">选择声音文件</label>
|
||||
<input id="songFile" type="file" name="songFile" accept="audio/*" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="imgFile">选择声音封面(可选)</label>
|
||||
<input id="imgFile" type="file" name="imgFile" accept="image/*" />
|
||||
</div>
|
||||
|
||||
<button class="btn" @click="submit">上传</button>
|
||||
@ -251,29 +256,36 @@
|
||||
methods: {
|
||||
submit() {
|
||||
console.info('submit')
|
||||
const file = document.querySelector('input[type=file]').files[0]
|
||||
if (!file) {
|
||||
alert('请选择文件')
|
||||
const songFile = document.querySelector('input[name=songFile]').files[0]
|
||||
const imgFile = document.querySelector('input[name=imgFile]').files[0]
|
||||
if (!songFile) {
|
||||
alert('请选择声音文件')
|
||||
return
|
||||
}
|
||||
this.upload(file)
|
||||
this.upload(songFile, imgFile)
|
||||
},
|
||||
|
||||
async getData() {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await axios({
|
||||
url: `/voicelist/search?cookie=${localStorage.getItem('cookie')}`,
|
||||
url: `/voicelist/my/created?limit=100&cookie=${localStorage.getItem(
|
||||
'cookie',
|
||||
)}`,
|
||||
})
|
||||
|
||||
console.info(res.data.data)
|
||||
this.voicelist = res.data.data.list || []
|
||||
this.voicelist = res.data.data.data || []
|
||||
this.voicelist.forEach(async (i) => {
|
||||
try {
|
||||
const res2 = await axios({
|
||||
url: `/voicelist/list?voiceListId=${i.voiceListId}&limit=5`,
|
||||
url: `/voicelist/list?voiceListId=${
|
||||
i.voiceListId
|
||||
}&limit=5&cookie=${localStorage.getItem('cookie')}`,
|
||||
})
|
||||
i.voiceListData = res2.data.data.list || []
|
||||
i.voiceListData = res2.data.data
|
||||
? res2.data.data.list || []
|
||||
: []
|
||||
console.info(res2)
|
||||
} catch (err) {
|
||||
console.error('获取播客详情失败:', err)
|
||||
@ -286,14 +298,17 @@
|
||||
}
|
||||
},
|
||||
|
||||
upload(file) {
|
||||
upload(songFile, imgFile) {
|
||||
if (!this.currentVoice) {
|
||||
alert('请先选择播客列表')
|
||||
return
|
||||
}
|
||||
|
||||
var formData = new FormData()
|
||||
formData.append('songFile', file)
|
||||
formData.append('songFile', songFile)
|
||||
if (imgFile) {
|
||||
formData.append('imgFile', imgFile)
|
||||
}
|
||||
|
||||
axios({
|
||||
method: 'post',
|
||||
@ -312,7 +327,7 @@
|
||||
data: formData,
|
||||
})
|
||||
.then((res) => {
|
||||
alert(`${file.name} 上传成功`)
|
||||
alert(`${songFile.name} 上传成功`)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('上传失败:', err)
|
||||
|
||||
129
test/voice_upload.test.js
Normal file
129
test/voice_upload.test.js
Normal file
@ -0,0 +1,129 @@
|
||||
const assert = require('assert')
|
||||
const voiceUpload = require('../module/voice_upload')
|
||||
|
||||
function createVoiceUploadHarness(uploadPlugin) {
|
||||
const requestCalls = []
|
||||
|
||||
return {
|
||||
requestCalls,
|
||||
dependencies: {
|
||||
uploadPlugin,
|
||||
axios: async (options) => {
|
||||
if (options.method === 'post' && options.url.endsWith('?uploads')) {
|
||||
return {
|
||||
data: '<InitiateMultipartUploadResult><UploadId>upload-id</UploadId></InitiateMultipartUploadResult>',
|
||||
}
|
||||
}
|
||||
|
||||
if (options.method === 'put') {
|
||||
return { headers: { etag: 'etag-1' } }
|
||||
}
|
||||
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
request: async (uri, data, options) => {
|
||||
requestCalls.push({ uri, data, options })
|
||||
|
||||
if (uri === '/api/nos/token/alloc') {
|
||||
return {
|
||||
body: {
|
||||
result: {
|
||||
objectKey: 'voice/audio.mp3',
|
||||
docId: 'audio-doc-id',
|
||||
token: 'nos-token',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (uri === '/api/voice/workbench/voice/batch/upload/v2') {
|
||||
return { body: { data: { voiceId: 'voice-id' } } }
|
||||
}
|
||||
|
||||
return { body: { code: 200 } }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createVoiceUploadQuery(overrides = {}) {
|
||||
const data = Buffer.from('audio')
|
||||
|
||||
return {
|
||||
songFile: {
|
||||
name: 'episode.mp3',
|
||||
mimetype: 'audio/mpeg',
|
||||
size: data.length,
|
||||
data,
|
||||
},
|
||||
voiceListId: 'voice-list-id',
|
||||
categoryId: 'category-id',
|
||||
secondCategoryId: 'second-category-id',
|
||||
description: 'episode description',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('voice upload cover', () => {
|
||||
it('uses the uploaded image as the cover for every voice submission', async () => {
|
||||
let uploadedQuery
|
||||
const harness = createVoiceUploadHarness(async (query) => {
|
||||
uploadedQuery = query
|
||||
return { imgId: 'uploaded-cover-id' }
|
||||
})
|
||||
const query = createVoiceUploadQuery({
|
||||
imgFile: {
|
||||
name: 'cover.jpg',
|
||||
mimetype: 'image/jpeg',
|
||||
data: Buffer.from('image'),
|
||||
},
|
||||
coverImgId: 'fallback-cover-id',
|
||||
})
|
||||
|
||||
const result = await voiceUpload(
|
||||
query,
|
||||
harness.request,
|
||||
harness.dependencies,
|
||||
)
|
||||
const voiceCalls = harness.requestCalls.filter((call) =>
|
||||
call.uri.startsWith('/api/voice/workbench/voice/batch/upload'),
|
||||
)
|
||||
|
||||
assert.strictEqual(uploadedQuery, query)
|
||||
assert.strictEqual(voiceCalls.length, 2)
|
||||
voiceCalls.forEach((call) => {
|
||||
const [voiceData] = JSON.parse(call.data.voiceData)
|
||||
assert.strictEqual(voiceData.coverImgId, 'uploaded-cover-id')
|
||||
})
|
||||
assert.deepStrictEqual(result, {
|
||||
status: 200,
|
||||
body: {
|
||||
code: 200,
|
||||
data: { voiceId: 'voice-id' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps using coverImgId when no image file is uploaded', async () => {
|
||||
let uploaded = false
|
||||
const harness = createVoiceUploadHarness(async () => {
|
||||
uploaded = true
|
||||
return { imgId: 'unexpected-cover-id' }
|
||||
})
|
||||
|
||||
await voiceUpload(
|
||||
createVoiceUploadQuery({ coverImgId: 'existing-cover-id' }),
|
||||
harness.request,
|
||||
harness.dependencies,
|
||||
)
|
||||
|
||||
const voiceCalls = harness.requestCalls.filter((call) =>
|
||||
call.uri.startsWith('/api/voice/workbench/voice/batch/upload'),
|
||||
)
|
||||
assert.strictEqual(uploaded, false)
|
||||
voiceCalls.forEach((call) => {
|
||||
const [voiceData] = JSON.parse(call.data.voiceData)
|
||||
assert.strictEqual(voiceData.coverImgId, 'existing-cover-id')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user