292 lines
9.2 KiB
TypeScript
292 lines
9.2 KiB
TypeScript
import { readBody } from 'h3'
|
|
import { db } from '~/drizzle/db'
|
|
import { songs } from '~/drizzle/schema'
|
|
import { eq } from 'drizzle-orm'
|
|
import { requireSongAdmin } from '~~/server/utils/requireSongAdmin'
|
|
import { createApiError } from '~~/server/utils/apiError'
|
|
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
|
import {
|
|
createTxSongDetailBody,
|
|
normalizeTxMusicId,
|
|
txHeaders
|
|
} from '~~/server/utils/native_tx'
|
|
import { fetchSongDuration } from '~~/server/utils/songDurationFetcher'
|
|
|
|
export default defineEventHandler(async (event) => {
|
|
requireSongAdmin(event)
|
|
|
|
const body = await readBody(event)
|
|
|
|
// 新增歌曲场景:未传 songId 时按平台+音乐ID获取时长,仅返回不写入
|
|
if (body?.songId == null) {
|
|
const platform = body?.platform
|
|
const musicId = body?.musicId
|
|
if (!platform || !musicId) {
|
|
throw createApiError(
|
|
400,
|
|
SERVER_ERROR_CODES.SONG_DURATION_PLATFORM_REQUIRED,
|
|
'缺少平台或音乐 ID 信息,无法获取时长'
|
|
)
|
|
}
|
|
const durationSeconds = await fetchSongDuration(String(platform), String(musicId))
|
|
if (durationSeconds == null) {
|
|
return { success: false, songId: null, durationSeconds: null, message: '未获取到有效时长' }
|
|
}
|
|
return { success: true, songId: null, durationSeconds, message: '时长获取成功' }
|
|
}
|
|
|
|
const songId = body.songId == null ? null : Number(body.songId)
|
|
|
|
if (!Number.isFinite(songId) || songId < 1) {
|
|
throw createApiError(400, SERVER_ERROR_CODES.SONG_INVALID_ID, '歌曲 ID 无效')
|
|
}
|
|
|
|
const song = await db.select().from(songs).where(eq(songs.id, songId)).limit(1)
|
|
if (song.length === 0) {
|
|
throw createApiError(404, SERVER_ERROR_CODES.SONG_NOT_FOUND, '歌曲不存在')
|
|
}
|
|
|
|
const s = song[0]
|
|
|
|
// 浏览器读取或管理员手动输入时,只由服务端负责校验并保存结果。
|
|
if (Object.prototype.hasOwnProperty.call(body || {}, 'durationSeconds')) {
|
|
if (body.durationSeconds === null) {
|
|
await db.update(songs).set({ durationSeconds: null }).where(eq(songs.id, songId))
|
|
return { success: true, songId, durationSeconds: null, message: '时长已清空' }
|
|
}
|
|
|
|
const durationSeconds = Number(body.durationSeconds)
|
|
if (!Number.isInteger(durationSeconds) || durationSeconds < 0 || durationSeconds > 7200) {
|
|
throw createApiError(
|
|
400,
|
|
SERVER_ERROR_CODES.COMMON_INVALID_PARAMS,
|
|
'时长需在 0 秒至 2 小时之间'
|
|
)
|
|
}
|
|
|
|
if (s.durationSeconds !== durationSeconds) {
|
|
await db.update(songs).set({ durationSeconds }).where(eq(songs.id, songId))
|
|
}
|
|
return {
|
|
success: true,
|
|
songId,
|
|
durationSeconds,
|
|
message: s.durationSeconds === durationSeconds ? '时长未变化' : '时长更新成功'
|
|
}
|
|
}
|
|
|
|
const platform = s.musicPlatform
|
|
const musicId = s.musicId
|
|
|
|
if (!platform || !musicId) {
|
|
throw createApiError(
|
|
400,
|
|
SERVER_ERROR_CODES.SONG_DURATION_PLATFORM_REQUIRED,
|
|
'歌曲缺少平台或音乐 ID 信息,无法获取时长'
|
|
)
|
|
}
|
|
|
|
let durationSeconds: number | null = null
|
|
|
|
try {
|
|
if (platform === 'netease' || platform === 'netease-podcast') {
|
|
const response: any = await $fetch('/api/api-enhanced/netease/song/url/v1', {
|
|
params: { id: musicId, level: 'standard' },
|
|
timeout: 10000
|
|
})
|
|
|
|
if (response?.code === 200 && Array.isArray(response.data) && response.data.length > 0) {
|
|
const timeMs = response.data[0]?.time
|
|
if (typeof timeMs === 'number' && timeMs > 0) {
|
|
durationSeconds = Math.floor(timeMs / 1000)
|
|
}
|
|
}
|
|
|
|
if (!durationSeconds) {
|
|
return {
|
|
success: false,
|
|
songId,
|
|
durationSeconds: null,
|
|
message: '网易云 API 未返回有效时长'
|
|
}
|
|
}
|
|
} else if (platform === 'tencent') {
|
|
const normalized = normalizeTxMusicId(musicId)
|
|
const result: any = await $fetch(
|
|
'https://u.y.qq.com/cgi-bin/musicu.fcg',
|
|
{
|
|
method: 'POST',
|
|
headers: txHeaders,
|
|
body: createTxSongDetailBody(normalized),
|
|
responseType: 'json',
|
|
timeout: 10000
|
|
}
|
|
)
|
|
|
|
if (result?.code === 0 && result?.req?.code === 0) {
|
|
const interval = result.req?.data?.track_info?.interval
|
|
if (typeof interval === 'number' && interval > 0) {
|
|
durationSeconds = Math.floor(interval)
|
|
}
|
|
}
|
|
|
|
if (!durationSeconds) {
|
|
return {
|
|
success: false,
|
|
songId,
|
|
durationSeconds: null,
|
|
message: 'QQ 音乐详情接口未返回有效时长'
|
|
}
|
|
}
|
|
} else if (platform === 'migu') {
|
|
const songInfo: any = await $fetch(
|
|
`https://app.c.nf.migu.cn/resource/song/by-contentids/v2.0?contentId=${encodeURIComponent(musicId)}`,
|
|
{ timeout: 10000 }
|
|
)
|
|
|
|
if (songInfo?.code?.toString() === '000000' && songInfo?.data?.length) {
|
|
const duration = songInfo.data[0]?.duration
|
|
if (typeof duration === 'number' && duration > 0) {
|
|
durationSeconds = Math.floor(duration)
|
|
}
|
|
}
|
|
|
|
if (!durationSeconds) {
|
|
return {
|
|
success: false,
|
|
songId,
|
|
durationSeconds: null,
|
|
message: '咪咕歌曲信息接口未返回有效时长'
|
|
}
|
|
}
|
|
} else if (platform === 'bilibili') {
|
|
// musicId 格式为 bvid:cid 或 bvid:cid:page
|
|
const parts = musicId.split(':')
|
|
const bvid = parts[0]
|
|
const page = parts.length > 2 ? Number(parts[2]) : 1
|
|
if (parts.length > 2 && (!Number.isInteger(page) || page < 1)) {
|
|
return {
|
|
success: false,
|
|
songId,
|
|
durationSeconds: null,
|
|
message: `哔哩哔哩视频 ID 格式无效:${musicId}`
|
|
}
|
|
}
|
|
const viewResp: any = await $fetch(
|
|
'https://api.bilibili.com/x/web-interface/view',
|
|
{
|
|
params: { bvid },
|
|
headers: {
|
|
Cookie: 'buvid3=0',
|
|
Referer: 'https://www.bilibili.com/'
|
|
},
|
|
timeout: 10000
|
|
}
|
|
)
|
|
|
|
if (viewResp?.code === 0 && viewResp?.data?.pages?.length) {
|
|
const pageIdx = page > 1 ? Math.max(0, page - 1) : 0
|
|
const duration = viewResp.data.pages[pageIdx]?.duration
|
|
if (typeof duration === 'number' && duration > 0) {
|
|
durationSeconds = Math.floor(duration)
|
|
}
|
|
}
|
|
|
|
if (!durationSeconds) {
|
|
return {
|
|
success: false,
|
|
songId,
|
|
durationSeconds: null,
|
|
message: '哔哩哔哩视频详情接口未返回有效时长'
|
|
}
|
|
}
|
|
} else {
|
|
return {
|
|
success: false,
|
|
songId,
|
|
durationSeconds: null,
|
|
message: `暂不支持的平台:${platform}`
|
|
}
|
|
}
|
|
|
|
// 网易云固定返回 30 秒时通常是试听片段,不作为歌曲完整时长。
|
|
if ((platform === 'netease' || platform === 'netease-podcast') && durationSeconds === 30) {
|
|
return {
|
|
success: false,
|
|
songId,
|
|
durationSeconds: null,
|
|
message: '网易云返回试听片段时长,未写入'
|
|
}
|
|
}
|
|
|
|
// 校验时长范围(合理的歌曲时长:30秒~1小时)
|
|
if (durationSeconds < 30 || durationSeconds > 3600) {
|
|
console.warn(`[时长刷新] 歌曲 #${songId} 时长异常:${durationSeconds}s,跳过写入`)
|
|
return {
|
|
success: false,
|
|
songId,
|
|
durationSeconds: null,
|
|
message: `获取到的时长 ${durationSeconds}s 超出合理范围(30秒~1小时),未写入`
|
|
}
|
|
}
|
|
|
|
// 避免无变化写入
|
|
if (s.durationSeconds === durationSeconds) {
|
|
return {
|
|
success: true,
|
|
songId,
|
|
durationSeconds,
|
|
message: '时长未变化'
|
|
}
|
|
}
|
|
|
|
// 写入数据库
|
|
await db.update(songs).set({ durationSeconds }).where(eq(songs.id, songId))
|
|
|
|
return {
|
|
success: true,
|
|
songId,
|
|
durationSeconds,
|
|
message: '时长获取成功'
|
|
}
|
|
} catch (err: any) {
|
|
const errorMsg = err?.message || '未知错误'
|
|
const platformName = platform === 'netease' || platform === 'netease-podcast' ? '网易云' : platform === 'tencent' ? 'QQ音乐' : platform === 'migu' ? '咪咕' : platform === 'bilibili' ? 'Bilibili' : platform
|
|
const statusCode = err.statusCode || err.status || (err.response?.status as number | undefined)
|
|
// AbortError:请求超时或批量刷新中止
|
|
if (err.name === 'AbortError' || err.name === 'TimeoutError') {
|
|
return {
|
|
success: false,
|
|
songId,
|
|
durationSeconds: null,
|
|
message: `${platformName} 响应超时`
|
|
}
|
|
}
|
|
// 404:平台限流或歌曲不存在
|
|
if (statusCode === 404) {
|
|
return {
|
|
success: false,
|
|
songId,
|
|
durationSeconds: null,
|
|
message: `${platformName} 未找到该歌曲`
|
|
}
|
|
}
|
|
if (statusCode === 403 || statusCode === 429) {
|
|
return {
|
|
success: false,
|
|
songId,
|
|
durationSeconds: null,
|
|
message: `${platformName} 接口被限制,请稍后重试`
|
|
}
|
|
}
|
|
console.error(`[时长刷新] 歌曲 #${songId} 获取时长失败 (${platformName}):`, errorMsg)
|
|
|
|
return {
|
|
success: false,
|
|
songId,
|
|
durationSeconds: null,
|
|
message: `${platformName} 接口异常,请稍后重试`
|
|
}
|
|
}
|
|
})
|