Files
BYW-VoiceHub/VoiceHub-main/app/composables/useMusicSources.ts
T
Henry_Du 4ce4649aaf
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s
初次提交
2026-09-06 08:19:02 +08:00

1751 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 音源管理器 Composable
* 提供多音源搜索、故障转移和状态监控功能
*/
import {
getEnabledSources,
getSourceById,
MUSIC_SOURCE_CONFIG,
type MusicSearchParams,
type MusicSearchResult,
type MusicSource,
type MusicSourceConfig,
type SongDetailParams,
type SongDetailResult,
type SourceStatus
} from '~/utils/musicSources'
import { getBilibiliTrackUrl, searchBilibili, parseBilibiliId } from '~/utils/bilibiliSource'
/**
* 音源管理器 Composable
*/
export const useMusicSources = () => {
// 配置
const config = ref<MusicSourceConfig>(MUSIC_SOURCE_CONFIG)
// 当前使用的音源
const currentSource = ref<string>(config.value.primarySource)
// 音源状态
const sourceStatus = ref<Record<string, SourceStatus>>({})
/**
* 使用 Meting API 获取歌曲信息
* @param id 歌曲ID
* @param source Meting API 音源
* @returns Promise<{success: boolean, data?: any, error?: string}>
*/
const getMetingSongInfo = async (
id: string | number,
source: MusicSource
): Promise<{
success: boolean
data?: any
error?: string
}> => {
try {
const metingUrl = `${source.baseUrl}/?server=netease&type=song&id=${id}`
const response = await $fetch(metingUrl, {
timeout: source.timeout || 8000,
headers: source.headers
})
// Meting API 返回歌曲信息数组
if (Array.isArray(response) && response.length > 0) {
const songInfo = response[0]
return {
success: true,
data: {
name: songInfo.name,
artist: songInfo.artist,
url: songInfo.url,
pic: songInfo.pic,
lrc: songInfo.lrc
}
}
}
return { success: false, error: 'Meting API 未返回有效数据' }
} catch (error: any) {
return { success: false, error: error?.message || '未知错误' }
}
}
/**
* 验证播放链接的有效性
* @param url 播放链接
* @returns Promise<{valid: boolean, duration?: number, error?: string}>
*/
const validatePlayUrl = async (
url: string
): Promise<{
valid: boolean
duration?: number
error?: string
}> => {
try {
// 修复HTTP/HTTPS协议问题
let validatedUrl = url
if (url.startsWith('http://')) {
validatedUrl = url.replace('http://', 'https://')
}
// 使用HEAD请求检查链接可用性,避免下载整个文件
const response = await fetch(validatedUrl, {
method: 'HEAD',
signal: AbortSignal.timeout(5000) // 5秒超时
})
if (!response.ok) {
return {
valid: false,
error: `HTTP ${response.status}: ${response.statusText}`
}
}
// 检查Content-Type是否为音频文件
const contentType = response.headers.get('content-type')
if (contentType && !contentType.includes('audio') && !contentType.includes('video')) {
return {
valid: false,
error: `不是音频文件: ${contentType}`
}
}
// 尝试获取文件大小来估算时长
const contentLength = response.headers.get('content-length')
let estimatedDuration = 0
if (contentLength) {
const fileSizeBytes = parseInt(contentLength)
// 粗略估算:假设128kbps的音质,1MB约为64秒
estimatedDuration = (fileSizeBytes / 1024 / 1024) * 64
if (estimatedDuration < 5) {
return {
valid: false,
duration: estimatedDuration,
error: `歌曲时长过短: ${estimatedDuration.toFixed(1)}秒`
}
}
}
return {
valid: true,
duration: estimatedDuration
}
} catch (error: any) {
return {
valid: false,
error: error.message || '链接验证失败'
}
}
}
// 是否正在搜索
const isSearching = ref(false)
// 最后一次搜索的音源
const lastUsedSource = ref<string>('')
/**
* 使用 Native Music API 搜索
*/
const searchNativeMusic = async (params: MusicSearchParams): Promise<any[]> => {
const platform = params.platform || 'netease'
if (platform !== 'netease' && platform !== 'tencent') {
return []
}
const endpoint = platform === 'netease' ? 'wy' : 'tx'
const url = `/api/native-api/search/${endpoint}`
try {
console.log(`[searchNativeMusic] Requesting ${platform} search: ${params.keywords}`)
const response: any = await $fetch(url, {
params: {
str: params.keywords,
page: Math.floor((params.offset || 0) / (params.limit || 30)) + 1,
limit: params.limit || 30
}
})
if (!response || !response.list) {
return []
}
return response.list.map((item: any) => {
const isNetease = platform === 'netease'
// 统一使用 songmid 作为 ID
// 网易云: songmid 为数字 ID (e.g. 123456)
// QQ音乐: songmid 为字符串 MID (e.g. 004MmF3024567),这对 vkeys 接口更友好
const mid = item.songmid
// VoiceHub 内部 ID: 网易云使用数字ID,QQ音乐优先使用数字ID(songId)以便vkeys使用
const id = isNetease ? item.songmid : item.songId || item.songmid
return {
id: id?.toString(),
title: item.name,
artist: item.singer?.replace(/、/g, '/') || '未知艺术家',
cover: item.img,
album: item.albumName,
duration: isNetease ? item.duration * 1000 : item.duration, // Netease uses ms, Tencent uses s
musicPlatform: platform,
musicId: id?.toString(),
url: undefined,
hasUrl: false,
sourceInfo: {
// 伪装源名称,以便兼容后续的播放逻辑
// 网易云使用 netease-backup,QQ音乐使用 vkeys
source: isNetease ? 'netease-backup' : 'vkeys',
originalId: id?.toString(),
fetchedAt: new Date(),
mid: mid,
strMediaMid: item.strMediaMid, // Add strMediaMid
quality: item._types, // Store quality info from LX
types: item.types
}
}
})
} catch (error: any) {
console.warn(`[searchNativeMusic] Failed:`, error)
throw error
}
}
// 服务器是否在中国
const isServerInChina = ref<boolean | null>(null)
// 检测服务器位置
const checkServerLocation = async () => {
if (isServerInChina.value !== null) return
try {
const data = await $fetch('/api/system/location')
if (data && data.success) {
isServerInChina.value = data.data.isInChina
console.log(`[useMusicSources] 服务器位置检测: ${isServerInChina.value ? '中国' : '海外'}`)
}
} catch (e) {
console.warn('[useMusicSources] 服务器位置检测失败,默认为海外:', e)
isServerInChina.value = false
}
}
/**
* 搜索歌曲(带故障转移)
*/
const searchSongs = async (
params: MusicSearchParams,
signal?: AbortSignal
): Promise<MusicSearchResult> => {
// 检查请求是否已被取消
if (signal?.aborted) {
throw new DOMException('搜索请求已被取消', 'AbortError')
}
isSearching.value = true
// 确保已检测服务器位置
if (isServerInChina.value === null) {
await checkServerLocation()
}
try {
// 优先尝试 Native Music 本地集成搜索 (仅支持网易云和QQ音乐)
// 只有当不是播客搜索时才使用
// 策略调整:如果服务器在中国,优先使用 Native Music;如果在海外,跳过 Native Music 直接使用第三方 API (除非第三方都失败)
const platform = params.platform || 'netease'
const shouldUseNativeFirst = isServerInChina.value === true
if (
shouldUseNativeFirst &&
(platform === 'netease' || platform === 'tencent') &&
params.type !== 1009
) {
try {
console.log(`[searchSongs] 服务器位于国内,优先尝试 Native Music 搜索...`)
const result = await searchNativeMusic(params)
if (result && result.length > 0) {
currentSource.value = 'native-music'
// 这里不再将 lastUsedSource 设置为 'native-music',
// 而是根据平台回退到能提供播放链接的音源ID(如 netease-backup 或 vkeys)。
// 这样 getSongUrl 等后续方法就会使用该音源进行播放链接获取。
lastUsedSource.value = platform === 'netease' ? 'netease-backup' : 'vkeys'
return {
success: true,
source: 'native-music',
data: result,
error: undefined
}
}
} catch (e) {
console.warn('[searchSongs] Native Music 搜索失败,回退到其他音源:', e)
}
} else if (!shouldUseNativeFirst) {
console.log(`[searchSongs] 服务器位于海外,跳过 Native Music 优先使用第三方音源`)
}
const enabledSources = getEnabledSources()
const errors: any[] = []
if (enabledSources.length === 0) {
throw new Error('没有可用的音源')
}
// 根据平台选择合适的音源顺序
let sourcesToTry: MusicSource[]
if (params.type === 1009) {
// 播客/电台搜索:只允许使用 netease-backup 源
sourcesToTry = enabledSources.filter((s) => s.id.includes('netease-backup'))
if (sourcesToTry.length === 0) {
throw new Error('未找到支持播客搜索的音源(需网易云备用源)')
}
} else if (params.platform === 'bilibili') {
const bilibiliSource = enabledSources.find((s) => s.id === 'bilibili')
if (bilibiliSource) {
sourcesToTry = [bilibiliSource]
} else {
throw new Error('未启用哔哩哔哩音源')
}
} else if (params.platform === 'tencent') {
// QQ音乐平台:优先使用 vkeys v3,其次 vkeys v2;若全部失败,再尝试其他平台
const v3 = enabledSources.find((s) => s.id === 'vkeys-v3')
const v2 = enabledSources.find((s) => s.id === 'vkeys')
const tencentSources: MusicSource[] = []
if (v3) tencentSources.push(v3)
if (v2) tencentSources.push(v2)
console.log('QQ音乐平台搜索,优先使用 vkeys v3,失败回退到 v2')
// 第一阶段:仅尝试腾讯专用源
sourcesToTry = tencentSources
} else {
// 网易云音乐平台(默认):优先使用netease-backup系列,vkeys作为备用
const neteaseSources = enabledSources.filter((s) => s.id.includes('netease-backup'))
const vkeysSource = enabledSources.find((s) => s.id === 'vkeys')
const otherSources = enabledSources.filter(
(s) => !s.id.includes('netease-backup') && s.id !== 'vkeys'
)
sourcesToTry = [...neteaseSources, ...(vkeysSource ? [vkeysSource] : []), ...otherSources]
console.log('网易云音乐平台搜索,优先使用netease-backup系列音源')
}
// 按选定的顺序尝试每个音源
for (const source of sourcesToTry) {
// 在每次尝试前检查请求是否已被取消
if (signal?.aborted) {
throw new DOMException('搜索请求已被取消', 'AbortError')
}
try {
console.log(`尝试使用音源: ${source.name} (${source.id})`)
const result = await searchWithSource(source, params, signal)
// 更新状态
currentSource.value = source.id
lastUsedSource.value = source.id
updateSourceStatus(source.id, 'online')
return {
success: true,
source: source.id,
data: result,
error: undefined
}
} catch (error: any) {
console.warn(`音源 ${source.name} 搜索失败:`, error.message)
updateSourceStatus(source.id, 'error', error.message)
errors.push(error) // 收集错误
// 如果不是最后一个音源,继续尝试下一个
if (source !== sourcesToTry[sourcesToTry.length - 1]) {
console.log(`继续尝试下一个音源...`)
}
}
}
// 若为QQ音乐平台,尝试完腾讯源后允许作为最后手段切换到其他平台
if (params.platform === 'tencent') {
const otherSources = enabledSources.filter((s) => s.id !== 'vkeys-v3' && s.id !== 'vkeys')
if (otherSources.length) {
console.log('QQ音乐平台所有专用源失败,作为最后手段尝试其他平台音源')
for (const source of otherSources) {
if (signal?.aborted) {
throw new DOMException('搜索请求已被取消', 'AbortError')
}
try {
console.log(`尝试使用音源: ${source.name} (${source.id})`)
const result = await searchWithSource(source, params, signal)
currentSource.value = source.id
lastUsedSource.value = source.id
updateSourceStatus(source.id, 'online')
return { success: true, source: source.id, data: result, error: undefined }
} catch (error: any) {
console.warn(`音源 ${source.name} 搜索失败:`, error.message)
updateSourceStatus(source.id, 'error', error.message)
errors.push(error) // 收集错误
}
}
}
}
// 所有音源都失败了
if (errors.length > 0) {
const errorDetails = errors.map((e) => e.message || '未知错误').join('; ')
throw new Error(`所有音源均不可用: ${errorDetails}`)
}
throw new Error('所有音源均不可用,请稍后重试')
} finally {
isSearching.value = false
}
}
/**
* 使用指定音源搜索
*/
const searchWithSource = async (
source: MusicSource,
params: MusicSearchParams,
signal?: AbortSignal
): Promise<any[]> => {
const startTime = Date.now()
let url: string
let transformResponse: (data: any) => any[]
if (source.id === 'bilibili') {
try {
const result = await searchBilibili(params.keywords)
updateSourceStatus(source.id, 'online', undefined, Date.now() - startTime)
return result
} catch (error: any) {
updateSourceStatus(source.id, 'error', error.message, Date.now() - startTime)
throw error
}
} else if (source.id === 'vkeys-v3') {
// Vkeys v3 API(仅支持QQ音乐搜索)
const platform = params.platform || 'tencent'
if (platform !== 'tencent') {
throw new Error('vkeys v3 仅支持QQ音乐搜索')
}
const page = Math.floor((params.offset || 0) / (params.limit || 10)) + 1
const limit = params.limit || 10
url = `${source.baseUrl}/tencent/search/song?keyword=${encodeURIComponent(params.keywords)}&page=${page}&limit=${limit}`
transformResponse = (data: any) => transformVkeysV3TencentSearch(data)
} else if (source.id === 'vkeys') {
// Vkeys API - 根据用户选择的平台使用对应的API
const platform = params.platform || 'netease' // 使用用户选择的平台,默认网易云
// 根据平台使用不同的端点,都使用word参数
if (platform === 'tencent') {
// QQ音乐使用word参数和tencent端点
url = `${source.baseUrl}/tencent?word=${encodeURIComponent(params.keywords)}&num=${params.limit || 30}`
} else {
// 网易云使用word参数和netease端点
url = `${source.baseUrl}/netease?word=${encodeURIComponent(params.keywords)}&num=${params.limit || 30}&page=${Math.floor((params.offset || 0) / (params.limit || 30)) + 1}`
}
transformResponse = (data: any) => transformVkeysResponse(data, platform)
} else {
// 网易云备用API
// 如果提供了cookie,添加到查询参数中
let cookieParam = ''
if (params.cookie) {
// 将cookie编码后作为参数传递
cookieParam = `&cookie=${encodeURIComponent(params.cookie)}`
}
// 如果是播客/电台搜索 (type === 1009),使用 /cloudsearch 接口
if (params.type === 1009) {
// 播客/电台搜索接口参数:keywords=关键词, limit=数量, offset=偏移量, type=1009
url = `${source.baseUrl}/cloudsearch?keywords=${encodeURIComponent(params.keywords)}&limit=${params.limit || 20}&offset=${params.offset || 0}&type=1009${cookieParam}`
transformResponse = async (data: any) => await transformNeteaseResponse(data)
} else {
// 默认使用 cloudsearch 接口
url = `${source.baseUrl}/cloudsearch?keywords=${encodeURIComponent(params.keywords)}&limit=${params.limit || 30}&offset=${params.offset || 0}&type=${params.type || 1}${cookieParam}`
transformResponse = async (data: any) => await transformNeteaseResponse(data)
}
}
let response: any
let finalUrl = url
try {
console.log(`[${source.name}] 请求URL:`, finalUrl)
// v3 请求使用简单请求,不携带任何会触发预检的自定义头
const requestHeaders =
source.id === 'vkeys-v3'
? undefined
: ({
'Content-Type': 'application/json',
...source.headers
} as Record<string, string>)
response = await $fetch(finalUrl, {
timeout: source.timeout || config.value.timeout,
signal,
headers: requestHeaders
})
console.log(`[${source.name}] API响应:`, response)
const responseTime = Date.now() - startTime
updateSourceStatus(source.id, 'online', undefined, responseTime)
} catch (error: any) {
// 如果是网易云备用源且使用的是cloudsearch接口,尝试切换到search接口
if (source.id !== 'vkeys' && finalUrl.includes('/cloudsearch')) {
console.warn(`[${source.name}] cloudsearch接口失败,尝试使用search接口:`, error.message)
// 如果提供了cookie,添加到查询参数中
let cookieParam = ''
if (params.cookie) {
cookieParam = `&cookie=${encodeURIComponent(params.cookie)}`
}
const fallbackUrl = `${source.baseUrl}/search?keywords=${encodeURIComponent(params.keywords)}&limit=${params.limit || 30}&offset=${params.offset || 0}&type=${params.type || 1}${cookieParam}`
try {
console.log(`[${source.name}] 备用请求URL:`, fallbackUrl)
response = await $fetch(fallbackUrl, {
timeout: source.timeout || config.value.timeout,
signal,
headers: {
'Content-Type': 'application/json',
...source.headers
}
})
console.log(`[${source.name}] 备用API响应:`, response)
const responseTime = Date.now() - startTime
updateSourceStatus(source.id, 'online', undefined, responseTime)
finalUrl = fallbackUrl // 更新最终使用的URL
} catch (fallbackError: any) {
const responseTime = Date.now() - startTime
console.error(`[${source.name}] 备用接口也失败:`, fallbackError.message)
updateSourceStatus(
source.id,
'error',
`cloudsearch和search接口均失败: ${error.message}, ${fallbackError.message}`,
responseTime
)
throw fallbackError
}
} else {
const responseTime = Date.now() - startTime
console.error(`[${source.name}] 网络请求失败:`, error.message)
updateSourceStatus(source.id, 'error', error.message, responseTime)
throw error
}
}
// 单独处理数据转换,避免网络成功但转换失败的情况
try {
const result = await transformResponse(response)
console.log(`[${source.name}] 转换后的数据:`, result)
return result
} catch (error: any) {
console.error(`[${source.name}] 数据转换失败:`, error.message)
console.error(`[${source.name}] 原始响应数据:`, response)
throw new Error(`数据转换失败: ${error.message}`)
}
}
/**
* 获取歌曲详情
*/
const getSongDetail = async (params: SongDetailParams): Promise<SongDetailResult> => {
const enabledSources = getEnabledSources()
for (const source of enabledSources) {
try {
const result = await getSongDetailWithSource(source, params)
updateSourceStatus(source.id, 'online')
return {
success: true,
source: source.id,
data: { songs: result },
error: undefined
}
} catch (error: any) {
console.warn(`音源 ${source.name} 获取歌曲详情失败:`, error.message)
updateSourceStatus(source.id, 'error', error.message)
}
}
throw new Error('所有音源均不可用')
}
/**
* 使用指定音源获取歌曲详情
*/
const getSongDetailWithSource = async (
source: MusicSource,
params: SongDetailParams
): Promise<any[]> => {
const startTime = Date.now()
// 验证参数
if (!params.ids || (Array.isArray(params.ids) && params.ids.length === 0)) {
throw new Error('歌曲ID参数不能为空')
}
const ids = Array.isArray(params.ids) ? params.ids.join(',') : params.ids
// 再次验证处理后的ids
if (!ids || ids === 'undefined' || ids === 'null') {
throw new Error(`无效的歌曲ID: ${ids}`)
}
let url: string
let transformResponse: (data: any) => any[]
if (source.id === 'vkeys') {
// Vkeys API - 使用搜索接口,包含了歌曲详情
// 默认使用网易云端点进行详情搜索
url = `${source.baseUrl}/netease?word=${encodeURIComponent(ids)}&num=50`
transformResponse = (data: any) => transformVkeysResponse(data, 'netease')
} else {
// 网易云备用API
url = `${source.baseUrl}/song/detail?ids=${ids}`
transformResponse = (response: any) => {
if (response.code !== 200 || !response.songs) {
throw new Error(`API响应错误: ${response.message || '未知错误'}`)
}
return transformNeteaseDetailResponse(response)
}
}
try {
const response = await $fetch(url, {
timeout: source.timeout || config.value.timeout,
headers: {
'Content-Type': 'application/json',
...source.headers
}
})
const responseTime = Date.now() - startTime
updateSourceStatus(source.id, 'online', undefined, responseTime)
return transformResponse(response)
} catch (error: any) {
const responseTime = Date.now() - startTime
updateSourceStatus(source.id, 'error', error.message, responseTime)
throw error
}
}
/**
* 更新音源状态
*/
const updateSourceStatus = (
sourceId: string,
status: SourceStatus['status'],
errorMessage?: string,
responseTime?: number
) => {
sourceStatus.value[sourceId] = {
status,
responseTime,
lastCheck: new Date(),
errorMessage
}
}
/**
* 获取歌曲封面URL(网易云备用源)
* 实现两步搜索:先通过歌曲ID获取详情,然后提取封面URL
*/
const getSongPicUrl = async (id: number): Promise<string> => {
try {
// 获取网易云备用源
const enabledSources = getEnabledSources()
const neteaseSource = enabledSources.find((source) => source.id.includes('netease-backup'))
if (!neteaseSource) {
console.error('[getSongPicUrl] 未找到网易云备用源')
return ''
}
// 直接调用/song/detail接口获取歌曲详情
const response = await $fetch(`${neteaseSource.baseUrl}/song/detail`, {
params: { ids: id },
timeout: neteaseSource.timeout || 8000
})
// 从响应中提取封面URL
return response.songs?.[0]?.al?.picUrl || ''
} catch (error) {
console.error('[getSongPicUrl] 获取封面失败:', error)
return ''
}
}
/**
* 将VoiceHub音质数值映射到网易云API的level参数
*/
const mapQualityToLevel = (quality: number): string => {
// 根据网易云API文档映射音质等级
switch (quality) {
case 2:
return 'standard' // 标准 (128k)
case 3:
return 'higher' // 较高 (192k)
case 4:
return 'exhigh' // HQ极高 (320k)
case 5:
return 'lossless' // SQ无损
case 6:
return 'hires' // Hi-Res
case 9:
return 'jymaster' // 超清母带
default:
return 'exhigh' // 默认极高音质
}
}
/**
* 获取歌曲播放URL
* 根据平台选择合适的音源:网易云优先使用netease-backup;QQ音乐仅使用vkeys系(不跨平台)
*/
const getSongUrl = async (
id: number | string,
quality?: number,
platform?: string,
cookie?: string,
options?: {
unblock?: boolean
bilibiliCid?: string
}
): Promise<{
success: boolean
url?: string
error?: string
}> => {
// 特殊处理 netease-podcast 平台:使用网易云逻辑但禁用 unblock
if (platform === 'netease-podcast') {
platform = 'netease'
if (!options) {
options = {}
}
// 只有在未明确指定 unblock 时才强制设为 false
if (options.unblock === undefined) {
options.unblock = false
}
}
// 如果没有提供cookie且在客户端环境,尝试从localStorage获取
if (!cookie && import.meta.client) {
try {
const storedCookie = localStorage.getItem('netease_cookie')
if (storedCookie) {
cookie = storedCookie
}
} catch (e) {
// Ignore localStorage errors
}
}
try {
// 获取所有启用的音源
const enabledSources = getEnabledSources()
// 支持多个ID的批量查询(用逗号分隔)
let idParam = Array.isArray(id) ? id.join(',') : id.toString()
// 如果是 bilibili 平台且 id 包含 cid,解析出来
if (platform === 'bilibili' && !Array.isArray(id)) {
const parsed = parseBilibiliId(id)
idParam = parsed.bvid
if (parsed.cid) {
if (!options) {
options = {}
}
if (!options.bilibiliCid) {
options.bilibiliCid = parsed.cid
}
}
}
const hasNeteaseLogin = !!cookie
const requestedQuality =
quality !== undefined && quality !== null && quality !== '' ? Number(quality) : NaN
// 定义音源尝试顺序
const sourcesToTry: Array<{ source: MusicSource; type: 'netease' | 'tencent' | 'bilibili' }> =
[]
if (platform === 'bilibili') {
const bilibiliSource = enabledSources.find((source) => source.id === 'bilibili')
if (bilibiliSource) {
sourcesToTry.push({ source: bilibiliSource, type: 'bilibili' })
}
} else if (platform === 'tencent') {
// QQ音乐平台:优先 vkeys v3(获取音质列表并选择可用项),然后回退到 vkeys v2
const v3 = enabledSources.find((source) => source.id === 'vkeys-v3')
const v2 = enabledSources.find((source) => source.id === 'vkeys')
if (v3) {
sourcesToTry.push({ source: v3, type: 'tencent' })
}
if (v2) {
sourcesToTry.push({ source: v2, type: 'tencent' })
}
} else {
// 网易云音乐平台(默认):未登录优先 vkeys,已登录优先 netease-backup
const neteaseSource = enabledSources.find((source) => source.id.includes('netease-backup'))
const vkeysSource = enabledSources.find((source) => source.id === 'vkeys')
const orderedSources = hasNeteaseLogin
? [neteaseSource, vkeysSource]
: [vkeysSource, neteaseSource]
for (const source of orderedSources) {
if (source) {
sourcesToTry.push({ source, type: 'netease' })
}
}
// 添加 Meting API 备用源
const metingSources = enabledSources.filter((source) => source.id.startsWith('meting-'))
metingSources.forEach((source) => {
sourcesToTry.push({ source, type: 'netease' })
})
// 添加其他备用音源
const otherSources = enabledSources.filter(
(source) =>
source.id !== 'vkeys' &&
!source.id.includes('netease-backup') &&
!source.id.startsWith('meting-')
)
otherSources.forEach((source) => {
sourcesToTry.push({ source, type: 'netease' })
})
}
// 逐个尝试音源
for (const { source, type } of sourcesToTry) {
try {
let url: string | null = null
if (source.id === 'bilibili') {
const result = await getBilibiliTrackUrl(idParam, options?.bilibiliCid)
url = result.url
} else if (source.id === 'vkeys') {
// Vkeys API
const endpoint = type === 'tencent' ? 'tencent' : 'netease'
const qualityCandidates =
type === 'tencent'
? [!isNaN(requestedQuality) ? requestedQuality : 8]
: [
...new Set(
[
!isNaN(requestedQuality) ? requestedQuality : 0,
!hasNeteaseLogin && requestedQuality > 4 ? 4 : null
].filter((value): value is number => typeof value === 'number')
)
]
for (const candidateQuality of qualityCandidates) {
const vkeysUrl = `${source.baseUrl}/${endpoint}?id=${idParam}&quality=${candidateQuality}`
const vkeysResp = await $fetch(vkeysUrl, { timeout: source.timeout || 8000 })
if (vkeysResp?.code === 200 && vkeysResp?.data?.url) {
url = String(vkeysResp.data.url)
break
}
}
} else if (source.id === 'vkeys-v3') {
// Vkeys v3:先获取歌曲信息与音质列表,再按可用音质选择并调用 v2 获取可播放URL
try {
const infoUrl = `${source.baseUrl}/tencent/song/info?id=${encodeURIComponent(idParam)}`
const infoResp = await $fetch(infoUrl, { timeout: source.timeout || 8000 })
// v3 成功码为 0
if (typeof infoResp?.code !== 'number' || infoResp.code !== 0 || !infoResp?.data) {
throw new Error(
`v3 歌曲信息接口错误: ${infoResp?.message || '未知错误'} (code: ${infoResp?.code})`
)
}
const qualityInfo: Array<{
type: number
size: number
file: string
quality: string
}> = infoResp.data.qualityInfo || []
if (!Array.isArray(qualityInfo) || qualityInfo.length === 0) {
throw new Error('v3 歌曲信息缺少音质列表')
}
// 目标音质:优先使用传入quality,否则读取设置
let targetQuality: number
const numQuality =
quality !== undefined && quality !== null && quality !== '' ? Number(quality) : NaN
if (!isNaN(numQuality)) {
targetQuality = numQuality
} else {
try {
const { getQuality } = await import('./useAudioQuality')
const setting = getQuality('tencent')
targetQuality = Number(setting)
if (isNaN(targetQuality)) targetQuality = 8
} catch {
targetQuality = 8 // 默认 HQ 高音质
}
}
// 构造腾讯音质优先序列:以目标为首,其次按 QUALITY_OPTIONS.tencent 顺序降级,最后兜底常见音质
let fallbackList: number[] = []
try {
const { QUALITY_OPTIONS } = await import('./useAudioQuality')
const optionValues =
(QUALITY_OPTIONS as any)?.tencent?.map((o: any) => o.value) || []
fallbackList = optionValues.filter((v: number) => v !== targetQuality)
} catch {
// 兜底序列
fallbackList = [8, 4, 10, 11, 14]
}
const tryQualities = [targetQuality, ...fallbackList]
// 在 v3 音质列表中选择第一个 size>0 的可用音质
const available = tryQualities.find((q) =>
qualityInfo.some((qi) => qi.type === q && Number(qi.size) > 0)
)
const selectedQuality = available ?? targetQuality
// 使用 vkeys v2 点歌接口获取可播放URL(v3 不直接返回 URL)
const v2Source = getEnabledSources().find((s) => s.id === 'vkeys')
if (!v2Source) {
throw new Error('未配置 vkeys v2 音源以获取播放链接')
}
const v2Url = `${v2Source.baseUrl}/tencent?id=${idParam}&quality=${selectedQuality}`
const v2Resp = await $fetch(v2Url, { timeout: v2Source.timeout || 8000 })
if (v2Resp?.code === 200 && v2Resp?.data?.url) {
url = String(v2Resp.data.url)
} else {
// 如果首选失败,尝试在可用音质上继续降级(排除已尝试项)
for (const q of tryQualities.filter((q) => q !== selectedQuality)) {
const hasQuality = qualityInfo.some((qi) => qi.type === q && Number(qi.size) > 0)
if (!hasQuality) continue
const altUrl = `${v2Source.baseUrl}/tencent?id=${idParam}&quality=${q}`
const altResp = await $fetch(altUrl, { timeout: v2Source.timeout || 8000 })
if (altResp?.code === 200 && altResp?.data?.url) {
url = String(altResp.data.url)
break
}
}
}
// 若仍未拿到URL,抛出错误以便外层继续按源回退
if (!url) {
throw new Error('v3 信息+v2 点歌未获取到有效播放链接')
}
} catch (error: any) {
// vkeys v3 路径失败,继续回退其它音源
}
} else if (source.id.startsWith('meting-')) {
// Meting API - 获取播放链接
try {
// 首先尝试获取歌曲信息,包含播放链接
const songInfo = await getMetingSongInfo(idParam, source)
if (songInfo.success && songInfo.data?.url) {
// 从歌曲信息中提取播放链接
url = songInfo.data.url
} else {
// 如果获取歌曲信息失败,直接使用 URL 类型的 API
const metingUrl = `${source.baseUrl}/?server=netease&type=url&id=${idParam}`
// 对于 Meting API,我们需要处理重定向
const response = await fetch(metingUrl, {
method: 'GET',
headers: source.headers || {},
redirect: 'follow'
})
if (response.ok && response.url) {
url = response.url
}
}
} catch (error: any) {
// Meting API 获取失败,继续回退其它音源
}
} else {
// 网易云备用API
let neteaseQuality: number | null = null
// 优先使用传入的 quality 参数
if (quality !== undefined && quality !== null) {
neteaseQuality = Number(quality)
} else {
// 否则回退到全局设置
try {
const { getQuality } = await import('./useAudioQuality')
neteaseQuality = Number(getQuality('netease'))
} catch (error) {
// 无法获取音质设置,使用默认音质
}
}
if (
neteaseQuality === null ||
Number.isNaN(neteaseQuality) ||
(!hasNeteaseLogin && neteaseQuality > 4)
) {
neteaseQuality = 4
}
const level = mapQualityToLevel(neteaseQuality)
// 如果提供了cookie,添加到请求参数中
const params: any = {
id: idParam,
level: level,
// 如果用户已登录(有cookie),默认不启用 unblock(false),否则启用(true)
unblock: options?.unblock ?? (!cookie)
}
if (cookie) {
params.cookie = cookie
}
const response = await $fetch(`${source.baseUrl}/song/url/v1`, {
params,
timeout: source.timeout || 8000
})
if (response?.code === 200 && Array.isArray(response.data) && response.data[0]?.url) {
url = response.data[0].url as string
}
}
if (url) {
// 修复HTTP/HTTPS协议问题
if (url.startsWith('http://')) {
url = url.replace('http://', 'https://')
}
// 验证播放链接
const validation = await validatePlayUrl(url)
if (validation.valid) {
return { success: true, url }
} else {
// 继续尝试下一个音源
}
} else {
// 未返回有效链接,继续尝试下一个音源
}
} catch (error: any) {
// 当前音源获取失败,继续尝试下一个音源
// 继续尝试下一个音源
}
}
return { success: false, error: '所有音源均无法获取有效的播放链接' }
} catch (error: any) {
return { success: false, error: error?.message || '未知错误' }
}
}
/**
* 获取歌词(统一调度,支持优先级策略)
* NeteaseCloudMusicApi 优先,其次 vkeys;腾讯仅 vkeys
*/
const getLyrics = async (
platform: 'netease' | 'tencent',
id: number | string
): Promise<{
success: boolean
data?: { lrc: string; trans?: string; yrc?: string; ttml?: string }
error?: string
}> => {
try {
const settings = useLyricSettings()
const enabledSources = getEnabledSources()
const neteaseSource = enabledSources.find((source) => source.id.includes('netease-backup'))
const vkeysSource = enabledSources.find((source) => source.id === 'vkeys')
// 结果容器
const resultData = { lrc: '', trans: '', yrc: '', ttml: '' }
let hasResult = false
// 辅助函数:获取官方歌词 (网易云)
const fetchOfficial = async () => {
if (platform !== 'netease' || !neteaseSource) return
try {
const [lrcResp, yrcResp, ttmlResp] = await Promise.allSettled([
$fetch(`${neteaseSource.baseUrl}/lyric`, {
params: { id: id.toString() },
timeout: neteaseSource.timeout || 8000
}),
$fetch(`${neteaseSource.baseUrl}/lyric/new`, {
params: { id: id.toString() },
timeout: neteaseSource.timeout || 8000
}),
// 尝试获取 TTML 歌词
$fetch(`${neteaseSource.baseUrl}/lyric/ttml`, {
params: { id: id.toString() },
timeout: neteaseSource.timeout || 8000
}).catch(() => null)
])
if (lrcResp.status === 'fulfilled' && lrcResp.value?.code === 200) {
const lr = lrcResp.value
if (lr?.lrc?.lyric) resultData.lrc = lr.lrc.lyric
if (lr?.tlyric?.lyric) resultData.trans = lr.tlyric.lyric
}
if (yrcResp.status === 'fulfilled' && yrcResp.value?.code === 200) {
const yr = yrcResp.value
if (yr?.yrc?.lyric) resultData.yrc = yr.yrc.lyric
}
if (ttmlResp.status === 'fulfilled' && ttmlResp.value?.code === 200) {
const tr = ttmlResp.value
if (tr?.ttml && !resultData.ttml) resultData.ttml = tr.ttml
}
if (resultData.lrc || resultData.yrc || resultData.ttml) hasResult = true
} catch (e: any) {
console.warn('[getLyrics] NeteaseCloudMusicApi 获取失败:', e?.message || e)
}
}
// 辅助函数:获取 AMLL DB Server TTML
const fetchAMLL = async () => {
if (platform !== 'netease' || !settings.enableOnlineTTMLLyric.value) return
const serverUrl = settings.amllDbServer.value
if (!serverUrl) return
try {
const url = serverUrl.replace('%s', id.toString())
const ttml = await $fetch(url, { responseType: 'text' })
if (ttml && typeof ttml === 'string' && ttml.includes('<tt')) {
resultData.ttml = ttml
hasResult = true
}
} catch (e) {
// console.warn('[getLyrics] AMLL DB Server 获取失败')
}
}
// 辅助函数:获取 QQ 音乐 (vkeys)
const fetchQM = async () => {
if (!vkeysSource) return
// 如果禁用了 QM 歌词且不是强制 QM 模式,则跳过
if (!settings.enableQQMusicLyric.value && settings.lyricPriority.value !== 'qm') return
try {
let url: string
if (platform === 'netease') {
url = `${vkeysSource.baseUrl}/netease/lyric?id=${id}`
} else if (platform === 'tencent') {
url = `${vkeysSource.baseUrl}/tencent/lyric?id=${id}`
} else {
return
}
const resp = await $fetch(url, {
timeout: vkeysSource.timeout || 8000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
})
if (resp?.code === 200 && resp?.data) {
const d = resp.data
if (d.lrc) resultData.lrc = d.lrc
if (d.trans) resultData.trans = d.trans
if (d.yrc) resultData.yrc = d.yrc // QRC
if (d.lrc || d.yrc) hasResult = true
}
} catch (e) {
console.warn('[getLyrics] vkeys 获取失败:', e)
}
}
// 优先级策略执行
const priority = settings.lyricPriority.value
console.log(`[getLyrics] 使用策略: ${priority}, 平台: ${platform}, ID: ${id}`)
if (priority === 'qm') {
await fetchQM()
if (!hasResult) {
await Promise.all([fetchAMLL(), fetchOfficial()])
}
} else if (priority === 'ttml') {
await fetchAMLL()
// 如果 AMLL 没拿到 TTML,尝试官方
if (!resultData.ttml) {
await fetchOfficial()
} else {
// 即使拿到了 TTML,也可能需要官方的 LRC/翻译 作为补充(如果 AMLL 只有 TTML)
// 但通常 TTML 足够了。为了保险起见,如果缺 LRC,可以去官方拿一下?
// SPlayer 逻辑是:fetchTTMLLyric -> fetchOfficialLyric -> fetchQQMusicLyric
// 这里简化:如果 fetchAMLL 成功,我们认为主要任务完成,但为了完整性(如翻译),可以尝试官方
if (!resultData.lrc || !resultData.trans) {
await fetchOfficial()
}
}
if (!hasResult) {
await fetchQM()
}
} else if (priority === 'official') {
await fetchOfficial()
} else {
// auto / default
if (settings.enableQQMusicLyric.value) {
await fetchQM()
}
await Promise.all([fetchAMLL(), fetchOfficial()])
}
// 检查是否有结果
if (hasResult) {
return { success: true, data: resultData }
}
// 最后尝试 Meting API(仅支持网易云,作为最后的 Fallback)
if (platform === 'netease') {
const metingSources = enabledSources.filter((source) => source.id.startsWith('meting-'))
for (const metingSource of metingSources) {
try {
const metingUrl = `${metingSource.baseUrl}/?server=netease&type=lrc&id=${id}`
const resp = await $fetch(metingUrl, {
timeout: metingSource.timeout || 8000,
headers: metingSource.headers
})
// Meting API 直接返回歌词文本
if (resp && typeof resp === 'string' && resp.trim()) {
return {
success: true,
data: {
lrc: resp,
trans: '',
yrc: '',
ttml: ''
}
}
}
} catch (error: any) {
console.warn(
`[getLyrics] Meting API ${metingSource.name} 获取失败:`,
error?.message || error
)
}
}
}
return { success: false, error: '未获取到歌词' }
} catch (error: any) {
console.error('[getLyrics] 获取歌词失败:', error)
return { success: false, error: error?.message || '未知错误' }
}
}
/**
* 转换 Vkeys API 响应
*/
const transformVkeysResponse = (response: any, platform: string = 'netease'): any[] => {
console.log('[transformVkeysResponse] 开始转换数据:', { platform, response })
if (!response) {
console.log('[transformVkeysResponse] 响应为空')
throw new Error('API响应为空')
}
// 检查错误码,非200状态码抛出错误以触发备用源重试
if (response.code !== 200) {
const errorMessage = `vkeys API错误: ${response.message || '未知错误'} (code: ${response.code})`
console.log('[transformVkeysResponse] API错误:', errorMessage)
throw new Error(errorMessage)
}
if (!response.data) {
console.log('[transformVkeysResponse] 响应数据为空')
throw new Error('API响应数据为空')
}
if (platform === 'tencent') {
// QQ音乐返回数组格式
const songs = Array.isArray(response.data) ? response.data : [response.data]
console.log(`[transformVkeysResponse] QQ音乐处理 ${songs.length} 首歌曲`)
return songs.map((song: any, index: number) => {
const transformedSong = {
id: song.id || song.musicId,
title: song.song || song.name || song.title,
artist: song.singer || song.artist || '未知艺术家',
cover: song.cover,
album: song.album,
duration: song.duration || song.dt,
musicPlatform: 'tencent',
musicId: (song.id || song.musicId)?.toString(),
url: song.url, // 添加vkeys API返回的播放链接
hasUrl: !!song.url, // 标记是否有播放链接
sourceInfo: {
source: 'vkeys',
originalId: (song.id || song.musicId)?.toString(),
fetchedAt: new Date(),
mid: song.mid, // QQ音乐特有的mid字段
vid: song.vid, // QQ音乐特有的vid字段
quality: song.quality,
pay: song.pay,
subtitle: song.subtitle,
time: song.time,
type: song.type,
bpm: song.bpm,
grp: song.grp
}
}
console.log(`[transformVkeysResponse] QQ音乐转换歌曲 ${index + 1}:`, transformedSong)
return transformedSong
})
} else {
// 网易云返回数组格式
const songs = Array.isArray(response.data) ? response.data : [response.data]
console.log(`[transformVkeysResponse] 网易云处理 ${songs.length} 首歌曲`)
return songs.map((song: any, index: number) => {
const transformedSong = {
id: song.id,
title: song.song,
artist: song.singer || '未知艺术家',
cover: song.cover?.trim(), // 去除可能的空格
album: song.album,
duration: song.interval || song.time, // 网易云使用interval字段表示时长,备用time字段
musicPlatform: 'netease',
musicId: song.id?.toString(),
url: song.url, // 添加vkeys API返回的播放链接
hasUrl: !!song.url, // 标记是否有播放链接
sourceInfo: {
source: 'vkeys',
originalId: song.id?.toString(),
fetchedAt: new Date(),
quality: song.quality,
size: song.size,
kbps: song.kbps,
url: song.url,
link: song.link,
time: song.time
}
}
console.log(`[transformVkeysResponse] 网易云转换歌曲 ${index + 1}:`, transformedSong)
return transformedSong
})
}
}
/**
* 转换 Vkeys v3 QQ音乐搜索响应
* 文档: https://doc.vkeys.cn/v3/音乐模块/QQ音乐/搜索相关接口/2-song.html
*/
const transformVkeysV3TencentSearch = (response: any): any[] => {
if (!response) throw new Error('API响应为空')
if (typeof response.code !== 'number') throw new Error('响应缺少状态码')
// v3 成功码为 0
if (response.code !== 0) {
const msg = `vkeys v3 API错误: ${response.message || '未知错误'} (code: ${response.code})`
throw new Error(msg)
}
const data = response.data
if (!data || !Array.isArray(data.list)) {
throw new Error('v3 响应数据格式不正确')
}
return data.list.map((item: any) => {
const id = item.songID ?? item.songId ?? item.id
const duration = item.interval ?? item.duration
return {
id,
title: item.title,
artist: Array.isArray(item.singerList)
? item.singerList.map((s: any) => s.name).join('/')
: item.singer || '',
cover: item.albumImage || item.cover || '',
album: item.album || '',
duration,
musicPlatform: 'tencent',
musicId: id?.toString(),
url: undefined,
hasUrl: false,
sourceInfo: {
source: 'vkeys-v3',
originalId: id?.toString(),
fetchedAt: new Date(),
mid: item.songMID || item.mid,
vid: item.vid,
quality: item.quality,
pay: item.pay,
subtitle: item.subtitle,
time: item.time,
type: item.type,
bpm: item.bpm,
grp: item.grp
}
}
})
}
/**
* 转换网易云 API 搜索响应
* 实现两步搜索:处理搜索结果,然后批量获取歌曲详情和封面
*/
const transformNeteaseResponse = async (response: any): Promise<any[]> => {
console.log('[transformNeteaseResponse] 开始转换数据:', response)
// 检查响应是否存在
if (!response) {
throw new Error('API响应为空')
}
// 检查API响应状态码
if (response.code !== undefined && response.code !== 200) {
throw new Error(`API响应错误: ${response.message || '未知错误'} (code: ${response.code})`)
}
// 尝试获取结果列表,支持多种类型(单曲、电台、声音等)
const result = response.result || response.data
if (!result) {
throw new Error('API响应格式错误: 缺少result/data字段')
}
let items =
result.songs || result.djRadios || result.resources || result.voices || result.list || []
// 如果是声音搜索(type=2000),结果可能在 data.resources 或 data.list 中
if (!items || items.length === 0) {
if (result.data?.resources) {
items = result.data.resources
} else if (result.list) {
items = result.list
} else if (Array.isArray(result)) {
items = result
}
}
if (!Array.isArray(items)) {
console.error('[transformNeteaseResponse] 无效的响应结构:', {
keys: Object.keys(result),
itemsType: typeof items
})
// 如果找不到列表但也不是错误,可能就是没结果
return []
}
console.log(`[transformNeteaseResponse] 找到 ${items.length} 个结果`)
if (items.length === 0) {
return []
}
// 检查是否是歌曲类型的完整信息
// 只有当 items 是 songs 且包含 al.picUrl 时才认为是完整信息的歌曲
const isSongType = !!result.songs
const hasCompleteInfo = isSongType && items.some((item: any) => item.al?.picUrl)
console.log(
`[transformNeteaseResponse] 结果类型: ${isSongType ? '歌曲' : '其他'}, 完整信息: ${hasCompleteInfo}`
)
let detailResponse: any = null
const detailMap = new Map()
// 如果是search接口响应的歌曲(缺少完整信息),则需要获取详情
if (isSongType && !hasCompleteInfo) {
// 获取网易云备用源
const enabledSources = getEnabledSources()
const neteaseSource = enabledSources.find((source) => source.id.includes('netease-backup'))
if (neteaseSource) {
// 提取所有歌曲ID,准备批量获取详情
const songIds = items.map((song: any) => song.id).filter((id: any) => id)
console.log(`[transformNeteaseResponse] 准备批量获取 ${songIds.length} 首歌曲的详情`)
if (songIds.length > 0) {
try {
// 批量获取歌曲详情,包含封面信息
detailResponse = await $fetch(`${neteaseSource.baseUrl}/song/detail`, {
params: { ids: songIds.join(',') },
timeout: neteaseSource.timeout || 8000
})
console.log(`[transformNeteaseResponse] 批量获取详情成功`)
} catch (error) {
console.warn(
'[transformNeteaseResponse] 批量获取详情失败,将使用搜索结果的基本信息:',
error
)
}
}
// 创建详情映射,方便查找
if (detailResponse?.songs) {
detailResponse.songs.forEach((song: any) => {
detailMap.set(song.id, song)
})
}
}
}
// 转换搜索结果,根据接口类型使用不同的数据提取策略
return items
.map((item: any, index: number) => {
try {
let id = item.id
let title = item.name
let cover: string | null = null
let artist: string = '未知艺术家'
let album: string | undefined
let duration: number = 0
let musicId = item.id?.toString()
// 处理不同类型的数据结构
if (isSongType) {
// 歌曲类型处理
if (hasCompleteInfo) {
// cloudsearch接口响应,包含完整信息
cover = item.al?.picUrl || null
artist = item.ar?.map((a: any) => a.name).join('/') || '未知艺术家'
album = item.al?.name
duration = item.dt || 0
} else {
// search接口响应,需要从详情中获取信息
const detail = detailMap.get(item.id)
cover =
detail?.al?.picUrl ||
(item.album?.picId
? `https://p1.music.126.net/${item.album.picId}/${item.album.picId}.jpg`
: null)
artist = item.artists?.map((a: any) => a.name).join('/') || '未知艺术家'
album = item.album?.name
duration = item.duration || 0
}
} else {
// 其他类型(如声音、电台等)
// 尝试适配声音/播客结构
const baseInfo = item.baseInfo || item
// 优先使用 mainSong.id (声音) 或 mainTrackId (电台节目) 作为歌曲ID,以便能通过 song/url 接口播放
// 如果都没有,才使用 baseInfo.id (节目ID)
const songId = baseInfo.mainSong?.id || baseInfo.mainTrackId || baseInfo.id
id = songId || id
title = baseInfo.name || title
cover = baseInfo.coverUrl || baseInfo.picUrl || item.picUrl || null
// 尝试获取艺术家/主播信息
if (baseInfo.dj) {
artist = baseInfo.dj.nickname
} else if (item.dj) {
artist = item.dj.nickname
} else if (baseInfo.artist) {
artist = baseInfo.artist.name
}
// 尝试获取专辑/电台信息
if (baseInfo.radio) {
album = baseInfo.radio.name
} else if (item.radio) {
album = item.radio.name
}
duration = baseInfo.duration || item.duration || 0
// 声音ID
musicId = id?.toString()
}
const transformedSong = {
id: id,
title: title,
artist,
cover,
album,
duration,
musicPlatform: isSongType ? 'netease' : 'netease-podcast',
musicId: musicId,
sourceInfo: {
source: 'netease-backup',
originalId: musicId,
fetchedAt: new Date(),
hasDetail: hasCompleteInfo || !!detailMap.get(item.id),
interface: hasCompleteInfo ? 'cloudsearch' : 'search',
type: isSongType ? 'song' : 'voice'
}
}
// 只打印前几个结果,避免日志过多
if (index < 3) {
console.log(`[transformNeteaseResponse] 转换结果 ${index + 1}:`, transformedSong)
}
return transformedSong
} catch (error: any) {
console.error(
`[transformNeteaseResponse] 转换第 ${index + 1} 个结果失败:`,
error.message,
item
)
// 不抛出错误,而是跳过该项
return null
}
})
.filter((item: any) => item !== null)
}
/**
* 转换网易云 API 详情响应
*/
const transformNeteaseDetailResponse = (response: any): any[] => {
return response.songs.map((song: any) => ({
id: song.id,
title: song.name,
artist: song.ar?.map((artist: any) => artist.name).join('/') || '未知艺术家',
cover: song.al?.picUrl,
album: song.al?.name,
duration: song.dt,
fee: song.fee,
privilege: song.privilege,
musicPlatform: 'netease',
musicId: song.id.toString(),
sourceInfo: {
source: 'netease-backup',
originalId: song.id.toString(),
fetchedAt: new Date()
}
}))
}
/**
* 获取音源状态摘要
*/
const getSourceStatusSummary = computed(() => {
const summary = {
total: config.value.sources.length,
online: 0,
offline: 0,
error: 0,
unknown: 0
}
config.value.sources.forEach((source) => {
const status = sourceStatus.value[source.id]
if (!status) {
summary.unknown++
} else {
summary[status.status]++
}
})
return summary
})
/**
* 获取当前音源信息
*/
const getCurrentSourceInfo = computed(() => {
const source = getSourceById(currentSource.value)
const status = sourceStatus.value[currentSource.value]
return {
source,
status,
isHealthy: status?.status === 'online'
}
})
/**
* 获取电台/播客节目列表
*/
const getDjPrograms = async (
radioId: string | number,
limit: number = 20,
offset: number = 0,
cookie?: string
): Promise<{
success: boolean
programs: any[]
count: number
more: boolean
error?: string
}> => {
const enabledSources = getEnabledSources()
// 优先使用网易云备用源
const neteaseSources = enabledSources.filter((source) => source.id.includes('netease-backup'))
// 如果没有找到网易云备用源,尝试其他可能支持的源(虽然目前只有备用源支持此接口)
if (neteaseSources.length === 0) {
return {
success: false,
programs: [],
count: 0,
more: false,
error: '未找到可用的网易云音源'
}
}
// 尝试所有网易云源
for (const source of neteaseSources) {
try {
let cookieParam = ''
if (cookie) {
cookieParam = `&cookie=${encodeURIComponent(cookie)}`
}
const url = `${source.baseUrl}/dj/program?rid=${radioId}&limit=${limit}&offset=${offset}${cookieParam}`
console.log(`[getDjPrograms] Requesting: ${url}`)
const response: any = await $fetch(url, {
timeout: source.timeout || 8000
})
if (response.code === 200) {
return {
success: true,
programs: response.programs || [],
count: response.count || 0,
more: response.more || false
}
}
} catch (e: any) {
console.warn(`[getDjPrograms] Source ${source.id} failed:`, e.message)
}
}
return { success: false, programs: [], count: 0, more: false, error: '获取节目列表失败' }
}
return {
// 状态
config: readonly(config),
currentSource: readonly(currentSource),
sourceStatus: readonly(sourceStatus),
isSearching: readonly(isSearching),
lastUsedSource: readonly(lastUsedSource),
// 计算属性
sourceStatusSummary: getSourceStatusSummary,
currentSourceInfo: getCurrentSourceInfo,
// 方法
searchSongs,
getSongDetail,
getSongUrl,
getLyrics,
getMetingSongInfo,
updateSourceStatus,
validatePlayUrl,
getDjPrograms
}
}