Files
BYW-VoiceHub/app/composables/useMusicSources.ts
T

2531 lines
88 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,
getVkeysIdParam,
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'
import { evaluateLyricDataMatch } from '~/utils/lyric/lyricMatchQuality'
import { useLyricSettings } from './useLyricSettings'
import { usePlatformConfig } from './usePlatformConfig'
import { useServerErrors } from './useLocaleText'
import { getPlatformDisplayName } from '~/utils/platforms'
// 歌词请求缓存,避免同一首歌重复请求
const lyricCache = new Map<string, Promise<any>>()
const lyricProgressSubscribers = new Map<string, Set<LyricProgressCallback>>()
const LYRIC_CACHE_TTL = 60 * 1000
// 服务器位置检测(模块级单例,避免多次实例化导致重复请求)
const globalIsServerInChina = ref<boolean | null>(null)
let locationCheckPromise: Promise<void> | null = null
// 检测失败后的冷却时间(5 分钟),避免瞬时失败导致长期锁死
const LOCATION_CHECK_RETRY_TTL = 5 * 60 * 1000
let locationCheckFailAt = 0
const checkServerLocationGlobal = async () => {
if (globalIsServerInChina.value !== null) return
// 检测失败后冷却期内不再重试
if (locationCheckFailAt && Date.now() < locationCheckFailAt) return
// 并发调用时合并为一次请求
if (locationCheckPromise) return locationCheckPromise
locationCheckPromise = (async () => {
try {
const data = await $fetch('/api/system/location')
if (data && data.success) {
globalIsServerInChina.value = data.data.isInChina
locationCheckFailAt = 0
console.log(
`[useMusicSources] 服务器位置检测: ${globalIsServerInChina.value ? '中国' : '海外'}`
)
}
} catch (e) {
// 失败时保持未知状态(null),进入冷却期,避免把一次瞬时失败永久判定为海外
locationCheckFailAt = Date.now() + LOCATION_CHECK_RETRY_TTL
console.warn('[useMusicSources] 服务器位置检测失败,稍后重试:', e)
} finally {
locationCheckPromise = null
}
})()
return locationCheckPromise
}
type LyricProgressStage = 'official' | 'qm' | 'amll' | 'upgrade' | 'meting'
type LyricProgressPayload = {
stage: LyricProgressStage
data: LyricResultData
}
type LyricProgressCallback = (payload: LyricProgressPayload) => void
type LyricUpgradeMeta = {
title?: string
artist?: string
album?: string
/** 毫秒,用于跨平台匹配时过滤时长差异过大的候选 */
duration?: number
/** false = 明确禁止跨平台升级(防止递归),undefined/true = 允许 */
allowCrossPlatformUpgrade?: boolean
/** 歌词来源优先级,参与缓存键(切换来源后强制重新拉取时传入) */
priority?: string
/** 用于歌词页先显示基础歌词,再切换到更高阶歌词 */
onProgress?: LyricProgressCallback
}
type LyricResultData = {
lrc: string
trans?: string
yrc?: string
ttml?: string
/** YRC 对齐的翻译(ytlrc),时间戳与逐字主歌词行边界一致 */
ytrans?: string
}
/**
* 格式优先级:数字越小越高阶。
* 用于判断对侧平台的歌词是否值得覆盖当前结果。
*/
const LYRIC_FORMAT_RANK: Record<string, number> = {
ttml: 0,
qrc: 1,
yrc: 1, // yrc 与 qrc 同阶,都是逐字
lrc: 2,
none: 3
}
/** 当前 LyricResultData 的最高阶格式 rank(数字越小越高阶) */
const getCurrentRank = (data: LyricResultData): number => {
if (data.ttml) return LYRIC_FORMAT_RANK.ttml
if (data.yrc) return LYRIC_FORMAT_RANK.yrc
if (data.lrc) return LYRIC_FORMAT_RANK.lrc
return LYRIC_FORMAT_RANK.none
}
/** 歌词候选匹配文本归一化:小写,去标点/空白/常见联唱标记 */
const normalizeLyricMatchText = (value: string): string => {
return String(value || '')
.toLowerCase()
.replace(/\b(?:feat|ft)\.?\s*/gi, '')
.replace(/[&&]/g, 'and')
.replace(/[、;,,/|()()·・\s\-_'"`~!??!.。《》【】\[\]{}^*@#$%+=\\]+/g, '')
}
/** 双向 includes */
const bothContains = (a: string, b: string): boolean =>
a.length > 0 && b.length > 0 && (a.includes(b) || b.includes(a))
/** 拆分多歌手文本,避免同名歌曲只比较整串导致误判 */
const splitLyricMatchArtists = (value?: string): string[] =>
String(value || '')
.split(/[、&&;,,/|·・]+/g)
.map((artist) => normalizeLyricMatchText(artist))
.filter(Boolean)
const matchLyricArtists = (
candidateArtist: string | undefined,
trackArtists: readonly string[]
): { exact: boolean; contains: boolean } => {
if (!trackArtists.length) return { exact: false, contains: false }
const candFull = normalizeLyricMatchText(candidateArtist || '')
const candParts = splitLyricMatchArtists(candidateArtist)
if (!candFull) return { exact: false, contains: false }
const exact = trackArtists.some(
(artist) => candFull === artist || candParts.some((part) => part === artist)
)
if (exact) return { exact: true, contains: false }
const contains = trackArtists.some(
(artist) =>
artist.length >= 2 &&
(bothContains(candFull, artist) || candParts.some((part) => bothContains(part, artist)))
)
return { exact: false, contains }
}
const isDurationClose = (left?: number, right?: number, tolerance = 5000): boolean =>
typeof left === 'number' && typeof right === 'number' && Math.abs(left - right) <= tolerance
const VERSION_MARKERS: Array<{ key: string; pattern: RegExp }> = [
{ key: 'live', pattern: /\blive\b|现场/i },
{ key: 'remix', pattern: /\bremix\b|混音/i },
{ key: 'instrumental', pattern: /\binstrumental\b|伴奏/i },
{ key: 'acoustic', pattern: /\bacoustic\b|不插电/i },
{ key: 'cover', pattern: /\bcover\b|翻唱/i },
{ key: 'demo', pattern: /\bdemo\b/i },
{ key: 'remaster', pattern: /\bremaster(?:ed)?\b|重制/i },
{ key: 'edit', pattern: /\bedit\b/i },
{ key: 'new', pattern: /新版/i },
{ key: 'old', pattern: /旧版/i }
]
const getVersionMarkers = (value: string): string[] =>
VERSION_MARKERS.filter(({ pattern }) => pattern.test(value))
.map(({ key }) => key)
.sort()
const stripVersionMarkers = (value: string): string => {
const stripped = VERSION_MARKERS.reduce(
(result, { pattern }) => result.replace(pattern, ''),
value
).trim()
return stripped || value
}
const isSameVersion = (left: string, right: string): boolean => {
const leftMarkers = getVersionMarkers(left)
const rightMarkers = getVersionMarkers(right)
if (leftMarkers.length === 0 && rightMarkers.length === 0) return true
return leftMarkers.join('|') === rightMarkers.join('|')
}
/** 子串占长串的最低比例,过低视为巧合 */
const NAME_CONTAIN_MIN_RATIO = 0.34
/**
* 从候选列表中挑出最匹配的结果,返回候选对象或 null。
*
* 硬性条件(不满足直接跳过):
* - title 全等,或双向 includes 且短串占长串比例 ≥ NAME_CONTAIN_MIN_RATIO
* - 原歌曲有 artist 时,候选必须命中至少一个 artist(防止同名异歌手误匹配)
* - title 仅子串命中时,必须有 artist 或接近时长佐证(防止巧合子串误匹配)
* - 双方都有 duration 时,差距不能超过 max(8s, 曲长 × 4%)
* - 标题中的现场、混音、伴奏等版本标记必须一致
*
* 打分规则(分越高越优先,须 ≥ MIN_SCORE):
* - title 全等 +10,子串命中 +4
* - artist 全等 +5,双向 includes +2
* - album 全等 +2
* - duration 接近(±5s)+3
*/
const MIN_SCORE = 4
const pickBestLyricCandidate = <
T extends { title?: string; artist?: string; album?: string; duration?: number }
>(
candidates: T[],
track: { title?: string; artist?: string; album?: string; duration?: number }
): T | null => {
const trackTitle = normalizeLyricMatchText(stripVersionMarkers(track.title || ''))
const trackArtists = splitLyricMatchArtists(track.artist)
const trackAlbum = normalizeLyricMatchText(track.album || '')
const trackDuration = track.duration
let best: T | null = null
let bestScore = MIN_SCORE - 1
for (const candidate of candidates) {
const candTitle = normalizeLyricMatchText(stripVersionMarkers(candidate.title || ''))
const candAlbum = normalizeLyricMatchText(candidate.album || '')
if (!isSameVersion(candidate.title || '', track.title || '')) continue
// 硬性:title 必须匹配
const titleExact = candTitle.length > 0 && candTitle === trackTitle
if (!titleExact) {
if (!bothContains(candTitle, trackTitle)) continue
const longer = Math.max(candTitle.length, trackTitle.length)
const shorter = Math.min(candTitle.length, trackTitle.length)
if (shorter / longer < NAME_CONTAIN_MIN_RATIO) continue
}
// 硬性:时长差距超过动态容差直接跳过
if (
typeof trackDuration === 'number' &&
typeof candidate.duration === 'number' &&
Math.abs(trackDuration - candidate.duration) > Math.max(8000, trackDuration * 0.04)
)
continue
const artist = matchLyricArtists(candidate.artist, trackArtists)
// 硬性:原歌曲有歌手时,候选也必须命中歌手,避免同名异歌手歌词串台
if (trackArtists.length > 0 && !artist.exact && !artist.contains) continue
// 硬性:title 仅子串命中时,必须有歌手或时长佐证
if (
!titleExact &&
!artist.exact &&
!artist.contains &&
!isDurationClose(candidate.duration, trackDuration)
) {
continue
}
let score = titleExact ? 10 : 4
if (artist.exact) score += 5
else if (artist.contains) score += 2
if (trackAlbum && candAlbum === trackAlbum) score += 2
if (isDurationClose(candidate.duration, trackDuration)) score += 3
if (score > bestScore) {
bestScore = score
best = candidate
}
}
return best
}
const buildLyricUpgradeQueries = (meta: LyricUpgradeMeta) => {
const title = meta.title?.trim() || ''
const artist = meta.artist?.trim() || ''
const album = meta.album?.trim() || ''
const queries = new Set<string>()
if (title && artist) {
queries.add(`${title} ${artist}`)
queries.add(`${artist} ${title}`)
}
if (title) queries.add(title)
if (title && album) queries.add(`${title} ${album}`)
return [...queries].filter(Boolean)
}
const getLyricCacheKey = (platform: string, id: number | string, meta?: LyricUpgradeMeta) => {
const title = normalizeLyricMatchText(meta?.title || '')
const artist = normalizeLyricMatchText(meta?.artist || '')
const album = normalizeLyricMatchText(meta?.album || '')
// allowCrossPlatformUpgrade=false 与 undefined/true 的结果不同,需区分
const upgradeFlag = meta?.allowCrossPlatformUpgrade === false ? '0' : '1'
// 优先级不同时抓取路径不同,结果可能不同;无显式 priority 的调用不区分
const priority = meta?.priority || ''
const durationBucket = meta?.duration ? Math.round(meta.duration / 5000) : 0
return `lyric-v2:${platform}:${id}:${title}:${artist}:${album}:${durationBucket}:${upgradeFlag}:${priority}`
}
const cloneLyricData = (data: LyricResultData): LyricResultData => ({
lrc: data.lrc || '',
trans: data.trans || '',
yrc: data.yrc || '',
ttml: data.ttml || '',
ytrans: data.ytrans || ''
})
const subscribeLyricProgress = (
cacheKey: string,
callback?: LyricProgressCallback
): (() => void) => {
if (!callback) return () => {}
let subscribers = lyricProgressSubscribers.get(cacheKey)
if (!subscribers) {
subscribers = new Set()
lyricProgressSubscribers.set(cacheKey, subscribers)
}
subscribers.add(callback)
return () => {
const currentSubscribers = lyricProgressSubscribers.get(cacheKey)
if (!currentSubscribers) return
currentSubscribers.delete(callback)
if (currentSubscribers.size === 0) {
lyricProgressSubscribers.delete(cacheKey)
}
}
}
const emitLyricProgress = (cacheKey: string, stage: LyricProgressStage, data: LyricResultData) => {
const subscribers = lyricProgressSubscribers.get(cacheKey)
if (!subscribers?.size) return
const payload = { stage, data: cloneLyricData(data) }
for (const callback of subscribers) {
try {
callback(payload)
} catch (error) {
console.warn('[getLyrics] 歌词进度回调失败:', error)
}
}
}
/**
* 音源管理器 Composable
*/
export const useMusicSources = () => {
// 配置
const config = ref<MusicSourceConfig>(MUSIC_SOURCE_CONFIG)
// 当前使用的音源
const currentSource = ref<string>(config.value.primarySource)
// 音源状态
const sourceStatus = ref<Record<string, SourceStatus>>({})
// 平台启用配置(usePlatformConfig 内部为模块级缓存,各实例共享同一份引用)
const { enabledPlatforms: globalEnabledPlatforms } = usePlatformConfig()
const getMetingSongInfo = async (
id: string | number,
source: MusicSource
): Promise<{ success: boolean; data?: any; error?: string }> => {
try {
const response = await $fetch(source.baseUrl, {
params: { server: 'netease', type: 'song', id: String(id) },
timeout: source.timeout || 8000
})
if (Array.isArray(response) && response.length > 0) {
return { success: true, data: response[0] }
}
return { success: false, error: 'Meting API 未返回有效数据' }
} catch (error: any) {
return { success: false, error: error?.message || '未知错误' }
}
}
/**
* 尝试从对侧平台获取更高阶格式的歌词(TTML > QRC/YRC > LRC)。
*
* 触发条件(全部满足):
* 1. 调用方未显式禁止跨平台升级(allowCrossPlatformUpgrade !== false)
* 2. 当前数据不是最高阶(即无 TTML)
* 3. meta 提供了 title + artist 以便搜索
*
* 升级策略:
* - 目标平台为对侧(netease ↔ tencent,migu → netease)
* - 对侧结果必须比当前格式更高阶才接受
* - TTML 需要 enableOnlineTTMLLyric 开启才接受
*/
const tryUpgradeLyric = async (
platform: 'netease' | 'tencent' | 'migu',
currentData: LyricResultData,
meta?: LyricUpgradeMeta
): Promise<boolean> => {
if (meta?.allowCrossPlatformUpgrade === false) return false
if (!meta?.title || !meta?.artist) return false
if (currentData.ttml) return false
const currentRank = getCurrentRank(currentData)
const targetPlatform = platform === 'netease' ? 'tencent' : 'netease'
// 目标平台被管理员禁用时跳过跨平台歌词升级
if (import.meta.client && !globalEnabledPlatforms.value.includes(targetPlatform)) {
console.warn(`[getLyrics] 跨平台歌词升级跳过:目标平台 ${targetPlatform} 已被禁用`)
return false
}
const queries = buildLyricUpgradeQueries(meta)
if (queries.length === 0) return false
let matchedCandidates: Array<{
title: string
artist: string
album: string
duration?: number
_song: any
}> = []
for (const keywords of queries) {
try {
const searchResult = await searchSongs({
keywords,
platform: targetPlatform,
type: 1,
limit: 10
})
if (
!searchResult.success ||
!Array.isArray(searchResult.data) ||
!searchResult.data.length
) {
continue
}
const candidates = searchResult.data.map((song: any) => ({
title: song.title || song.name || '',
artist: song.artist || song.singer || '',
album: song.album || song.albumName || '',
duration: song.duration,
_song: song
}))
const best = pickBestLyricCandidate(candidates, meta)
if (best) {
matchedCandidates = candidates
break
}
} catch (error: any) {
console.warn('[getLyrics] 跨平台升级搜索失败:', error?.message || error)
}
}
const rejectedIds = new Set<string>()
for (let attempt = 0; attempt < 3; attempt++) {
const best = pickBestLyricCandidate(
matchedCandidates.filter((candidate) => {
const id = candidate._song?.musicId
return id && !rejectedIds.has(String(id))
}),
meta
)
if (!best?._song?.musicId) return false
const matchedTrack = best._song
rejectedIds.add(String(matchedTrack.musicId))
try {
const upgraded = await fetchLyricsWithoutUpgrade(
matchedTrack.musicPlatform || targetPlatform,
matchedTrack.musicId,
{
title: matchedTrack.title,
artist: matchedTrack.artist,
album: matchedTrack.album,
duration: best.duration,
allowCrossPlatformUpgrade: false
}
)
if (!upgraded.success || !upgraded.data) continue
const tryAccept = (
candidateData: { lrc?: string; yrc?: string; ttml?: string },
format: 'ttml' | 'yrc'
) => {
// 无基准歌词时(如咪咕官方歌词获取失败)无法做内容匹配,仅校验时长差异
if (!currentData.lrc && !currentData.yrc) {
if (meta.duration && best.duration) {
const durationDiffMs = Math.abs(meta.duration - best.duration)
if (durationDiffMs > Math.max(8000, meta.duration * 0.04)) {
console.info(
`[getLyrics] ${targetPlatform}:${matchedTrack.musicId} ${format} rejected/duration_mismatch`
)
return false
}
}
return true
}
const decision = evaluateLyricDataMatch(
{ lrc: currentData.lrc, yrc: currentData.yrc },
candidateData,
meta.duration,
best.duration
)
console.info(
`[getLyrics] ${targetPlatform}:${matchedTrack.musicId} ${format} ${decision.status}/${decision.reason}`,
decision.metrics
)
return decision.status === 'accepted'
}
if (
upgraded.data.ttml &&
useLyricSettings().enableOnlineTTMLLyric.value &&
LYRIC_FORMAT_RANK.ttml < currentRank &&
tryAccept({ ttml: upgraded.data.ttml }, 'ttml')
) {
currentData.ttml = upgraded.data.ttml
if (upgraded.data.trans && !currentData.trans) currentData.trans = upgraded.data.trans
return true
}
if (
upgraded.data.yrc &&
LYRIC_FORMAT_RANK.yrc < currentRank &&
tryAccept({ yrc: upgraded.data.yrc }, 'yrc')
) {
currentData.yrc = upgraded.data.yrc
if (upgraded.data.trans && !currentData.trans) currentData.trans = upgraded.data.trans
if (upgraded.data.ytrans && !currentData.ytrans) {
currentData.ytrans = upgraded.data.ytrans
}
return true
}
} catch (error: any) {
console.warn('[getLyrics] 跨平台升级获取失败:', error?.message || error)
}
}
return false
}
const fetchLyricsWithoutUpgrade = async (
platform: 'netease' | 'tencent' | 'migu',
id: number | string,
meta?: LyricUpgradeMeta
): Promise<{
success: boolean
data?: LyricResultData
error?: string
}> => {
const cacheKey = getLyricCacheKey(platform, id, meta)
const unsubscribeProgress = subscribeLyricProgress(cacheKey, meta?.onProgress)
const progressive = typeof meta?.onProgress === 'function'
const cached = lyricCache.get(cacheKey)
if (cached) {
return cached.finally(unsubscribeProgress)
}
const promise = (async () => {
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: LyricResultData = { lrc: '', trans: '', yrc: '', ttml: '' }
let hasResult = false
let lastProgressSignature = ''
let ttmlValidated = false
const emitProgress = (stage: LyricProgressStage) => {
const signature = [
stage,
resultData.lrc?.length || 0,
resultData.trans?.length || 0,
resultData.yrc?.length || 0,
resultData.ttml?.length || 0,
resultData.ytrans?.length || 0
].join(':')
if (signature === lastProgressSignature) return
lastProgressSignature = signature
emitLyricProgress(cacheKey, stage, resultData)
}
const validateTtml = (ttml: string): boolean => {
if (!resultData.lrc && !resultData.yrc) return true
const decision = evaluateLyricDataMatch(
{ lrc: resultData.lrc, yrc: resultData.yrc },
{ ttml },
meta?.duration,
meta?.duration
)
console.info(
`[getLyrics] ${platform}:${id} ttml ${decision.status}/${decision.reason}`,
decision.metrics
)
ttmlValidated = decision.status === 'accepted'
return ttmlValidated
}
const fetchOfficial = async () => {
if (platform !== 'netease' || !neteaseSource) return
try {
// /lyric/new 返回逐字歌词(yrc)及其对齐翻译(ytlrc),
// 时间戳贴 YRC 行边界,优先于普通 tlyric
const [lrcResp, yrcResp] = await Promise.allSettled([
$fetch(`${neteaseSource.baseUrl}/lyric`, {
params: { id: id.toString() },
timeout: neteaseSource.timeout || 8000
}),
$fetch(`${neteaseSource.baseUrl}/lyric/new`, {
params: {
id: id.toString(),
cp: false,
tv: 0,
lv: 0,
rv: 0,
kv: 0,
yv: 0,
ytv: 0,
yrv: 0
},
timeout: neteaseSource.timeout || 8000
})
])
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
// ytlrc 为 YRC 对齐翻译,时间戳与逐字行边界一致
if (yr?.ytlrc?.lyric) resultData.ytrans = yr.ytlrc.lyric
// 无 ytlrc 时用 tlyric 兜底
if (!resultData.ytrans && yr?.tlyric?.lyric && !resultData.trans) {
resultData.trans = yr.tlyric.lyric
}
}
if (resultData.lrc || resultData.yrc) {
hasResult = true
emitProgress('official')
}
} catch (e: any) {
console.warn('[getLyrics] NeteaseCloudMusicApi 获取失败:', e?.message || e)
}
}
const fetchAMLL = async () => {
// 锁定 TTML 来源时无视总开关
if (!settings.enableOnlineTTMLLyric.value && priority !== 'ttml') return
try {
let url: string
if (platform === 'tencent') {
url = `https://amlldb.bikonoo.com/lyrics/qq-lyrics/${id}`
} else if (platform === 'netease') {
const serverUrl = settings.amllDbServer.value
if (!serverUrl) return
url = serverUrl.replace('%s', id.toString())
} else {
return
}
const ttml = await $fetch(url, { responseType: 'text' })
if (ttml && typeof ttml === 'string' && ttml.includes('<tt')) {
if ((resultData.lrc || resultData.yrc) && !validateTtml(ttml)) return
resultData.ttml = ttml
hasResult = true
if (!progressive || ttmlValidated) emitProgress('amll')
}
} catch {}
}
const fetchQM = async () => {
if (!settings.enableQQMusicLyric.value && priority !== 'qm') return
if (platform === 'tencent') {
try {
let qqMusicCookie = ''
if (import.meta.client) {
qqMusicCookie = localStorage.getItem('qq_music_cookie') || ''
}
// 传入元信息以提高原生 QRC 接口匹配精度
const nativeResp = await $fetch('/api/native-api/lyric/tx', {
params: {
songmid: String(id),
...(meta?.title ? { name: meta.title } : {}),
...(meta?.artist ? { artist: meta.artist } : {}),
...(meta?.album ? { album: meta.album } : {}),
...(qqMusicCookie ? { cookie: qqMusicCookie } : {})
},
timeout: 8000
})
if (nativeResp?.success && nativeResp?.data) {
const d = nativeResp.data
// qrc 优先于 lrc,两者都写入,解析层按优先级选择
if (d.qrc) resultData.yrc = d.qrc // 用 yrc 字段承载 QRC,解析器会识别 XML 格式
if (d.lrc) resultData.lrc = d.lrc
if (d.trans) resultData.trans = d.trans
if (d.qrc || d.lrc) {
hasResult = true
emitProgress('qm')
return
}
}
} catch (e) {
console.warn('[getLyrics] 原生歌词接口失败:', e)
}
}
if (!vkeysSource) return
try {
let url: string
const lyricIdParam = getVkeysIdParam(platform as 'netease' | 'tencent', id)
if (platform === 'netease') {
url = `${vkeysSource.baseUrl}/netease/lyric?${lyricIdParam.key}=${encodeURIComponent(lyricIdParam.value)}`
} else if (platform === 'tencent') {
url = `${vkeysSource.baseUrl}/tencent/lyric?${lyricIdParam.key}=${encodeURIComponent(lyricIdParam.value)}`
} 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
if (d.lrc || d.yrc) {
hasResult = true
emitProgress('qm')
}
}
} catch (e) {
console.warn('[getLyrics] vkeys 获取失败:', e)
}
}
/**
* 获取咪咕歌词
*/
const fetchMigu = async () => {
if (platform !== 'migu') return
try {
const miguResp = await $fetch('/api/native-api/lyric/mg', {
params: {
contentId: String(id)
},
timeout: 10000
})
if (miguResp?.success && miguResp?.data) {
const d = miguResp.data
if (d.lrc) {
resultData.lrc = d.lrc
hasResult = true
emitProgress('official')
}
}
} catch (e) {
console.warn('[getLyrics] 咪咕歌词获取失败:', e)
}
}
const priority = meta?.priority || settings.lyricPriority.value
// 指定具体来源(非 auto)时锁定该来源,失败不回退、不做跨平台升级
const sourceLocked = priority === 'qm' || priority === 'official' || priority === 'ttml'
if (sourceLocked) {
if (platform === 'migu') {
await fetchMigu()
} else if (priority === 'qm') {
await fetchQM()
} else if (priority === 'official') {
// 锁定 NCM:官方接口,yrc 优先、lrc 兜底(fetchOfficial 内部已同时请求两者)
await fetchOfficial()
} else {
// 锁定 TTML:仅 AMLL DB
await fetchAMLL()
}
} else if (progressive) {
if (platform === 'netease') {
let triedQM = false
const fetchQMOnce = async () => {
if (triedQM) return
triedQM = true
await fetchQM()
}
await fetchOfficial()
if (priority === 'auto' && settings.enableQQMusicLyric.value) {
await fetchQMOnce()
}
await fetchAMLL()
if (!hasResult) {
await fetchQMOnce()
}
} else if (platform === 'migu') {
// 咪咕平台:直接获取咪咕歌词
await fetchMigu()
} else {
await fetchQM()
await fetchAMLL()
if (!hasResult) {
await fetchOfficial()
}
}
} else {
// 默认:AMLL → (QQ) → 官方
if (platform === 'migu') {
await fetchMigu()
} else {
await fetchAMLL()
if (settings.enableQQMusicLyric.value) {
await fetchQM()
}
await fetchOfficial()
}
}
if (resultData.ttml && (resultData.lrc || resultData.yrc) && !ttmlValidated) {
if (!validateTtml(resultData.ttml)) {
resultData.ttml = ''
} else if (progressive) {
emitProgress('amll')
}
}
// 跨平台升级:当前无 TTML 时尝试(有 yrc 也可升级到 ttml,有 lrc 可升级到 yrc/ttml)
// 指定具体来源时不做升级,保证结果确实来自所选来源
if (!sourceLocked && !resultData.ttml && (resultData.lrc || resultData.yrc || resultData.trans)) {
const upgraded = await tryUpgradeLyric(platform, resultData, meta)
if (upgraded) {
hasResult = true
emitProgress('upgrade')
}
}
// 咪咕官方歌词获取失败时(海外部署官方接口不可用),仍尝试跨平台升级作为兜底
if (platform === 'migu' && !hasResult) {
const upgraded = await tryUpgradeLyric(platform, resultData, meta)
if (upgraded) {
hasResult = true
emitProgress('upgrade')
}
}
if (hasResult) {
return { success: true, data: resultData }
}
if (!sourceLocked && platform === 'netease') {
const metingSources = enabledSources.filter((source) => source.id.startsWith('meting-'))
for (const source of metingSources) {
try {
const lyric = await $fetch(source.baseUrl, {
params: { server: 'netease', type: 'lrc', id: String(id) },
responseType: 'text',
timeout: source.timeout || 8000
})
if (lyric.trim()) {
resultData.lrc = lyric
emitProgress('meting')
return { success: true, data: cloneLyricData(resultData) }
}
} catch (error: any) {
console.warn(
`[getLyrics] Meting API ${source.name} 获取失败:`,
error?.message || error
)
}
}
}
return { success: false, error: '未获取到歌词' }
} catch (error: any) {
console.error('[getLyrics] 获取歌词失败:', error)
return { success: false, error: error?.message || '未知错误' }
}
})()
.then((res) => {
if (!res.success) {
lyricCache.delete(cacheKey)
}
return res
})
.finally(() => {
unsubscribeProgress()
setTimeout(() => {
if (lyricCache.get(cacheKey) === promise) {
lyricCache.delete(cacheKey)
}
}, LYRIC_CACHE_TTL)
})
lyricCache.set(cacheKey, promise)
return promise
}
/**
* 验证播放链接的有效性
* @param url 播放链接
* @returns Promise<{valid: boolean, duration?: number, error?: string}>
*/
const validatePlayUrl = async (
url: string
): Promise<{
valid: boolean
duration?: number
error?: string
}> => {
if (!import.meta.client) {
return {
valid: true
}
}
try {
let validatedUrl = url
if (url.startsWith('http://')) {
validatedUrl = url.replace('http://', 'https://')
}
const exactDuration = await new Promise<number>((resolve, reject) => {
const audio = new Audio()
let settled = false
const timeout = window.setTimeout(() => {
finalize(new Error('获取音频元数据超时'))
}, 8000)
const cleanup = () => {
window.clearTimeout(timeout)
audio.onloadedmetadata = null
audio.onerror = null
audio.pause()
audio.src = ''
audio.load()
}
const finalize = (result: number | Error) => {
if (settled) {
return
}
settled = true
cleanup()
if (result instanceof Error) {
reject(result)
return
}
resolve(result)
}
audio.preload = 'metadata'
audio.onloadedmetadata = () => {
const duration = audio.duration
if (!Number.isFinite(duration) || duration <= 0) {
finalize(new Error('无效的音频时长'))
return
}
finalize(duration)
}
audio.onerror = () => {
finalize(new Error('无法加载音频文件'))
}
audio.src = validatedUrl
audio.load()
})
if (exactDuration < 40) {
return {
valid: false,
duration: exactDuration,
error: `歌曲实际时长过短: ${exactDuration.toFixed(1)}秒`
}
}
return {
valid: true,
duration: exactDuration
}
} 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' && platform !== 'migu') {
return []
}
let endpoint: string
if (platform === 'netease') {
endpoint = 'wy'
} else if (platform === 'tencent') {
endpoint = 'tx'
} else {
endpoint = 'mg'
}
const url = `/api/native-api/search/${endpoint}`
const qqMusicCookie =
platform === 'tencent' && import.meta.client
? localStorage.getItem('qq_music_cookie') || undefined
: undefined
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,
cookie: qqMusicCookie
}
})
if (!response || !response.list) {
return []
}
return response.list.map((item: any) => {
const isNetease = platform === 'netease'
const isMigu = platform === 'migu'
const txSource = response.source === 'qq-music-api' ? 'qq-music-api' : 'native-tx'
const mid = item.songmid
const id = isNetease ? item.songmid : mid || item.songId
return {
id: id?.toString(),
title: item.name,
artist: item.singer?.replace(/、/g, '/') || '未知艺术家',
cover: item.img,
album: item.albumName,
albumId: item.albumId,
duration: isNetease ? item.duration * 1000 : item.duration, // 网易使用 ms,腾讯/咪咕使用 s
musicPlatform: platform,
musicId: id?.toString(),
url: undefined,
hasUrl: false,
sourceInfo: {
source: isNetease ? 'netease-backup' : isMigu ? 'migu' : txSource,
originalId: id?.toString(),
originalSongId: !isNetease && item.songId ? item.songId.toString() : undefined,
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 = globalIsServerInChina
// 检测服务器位置
const checkServerLocation = checkServerLocationGlobal
/**
* 搜索歌曲(带故障转移)
*/
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音乐)
// 策略调整:
// - QQ音乐平台:无论国内外均优先 Native Music
// - 网易云音乐平台:仅国内服务器优先 Native Music;海外跳过,直接使用第三方 API
const platform = params.platform || 'netease'
// 检查平台是否启用(SSR 阶段跳过,$fetch 无 cookie)
if (import.meta.client) {
if (!globalEnabledPlatforms.value.includes(platform)) {
const { currentLocale, siteConfig } = useLocale()
const available = globalEnabledPlatforms.value.filter((p) => p !== platform)
const platformName = getPlatformDisplayName(
platform,
siteConfig.value,
currentLocale.value
)
const availableNames = available
.map((p) => getPlatformDisplayName(p, siteConfig.value, currentLocale.value))
.join('、')
const { localize } = useServerErrors()
// 先按 code + params 本地化得到 message,再抛出结构化错误:
// data.code 供 extractErrorCode 提取,data.message 供 getThrownMessage 提取
const payload = {
data: {
code: 'MUSIC_SOURCE_PLATFORM_DISABLED',
params: [platformName, availableNames]
}
}
const err = new Error(localize(payload))
;(err as any).data = payload.data
throw err
}
}
const shouldUseNativeFirst = platform === 'tencent' || isServerInChina.value === true
if (
shouldUseNativeFirst &&
(platform === 'netease' || platform === 'tencent') &&
params.type !== 1009
) {
try {
const reason =
platform === 'tencent'
? '[searchSongs] QQ音乐平台,优先尝试 Native Music 搜索...'
: '[searchSongs] 服务器位于国内,优先尝试 Native Music 搜索...'
console.log(reason)
const result = await searchNativeMusic(params)
if (result && result.length > 0) {
currentSource.value = 'native-music'
lastUsedSource.value = platform === 'netease' ? 'netease-backup' : 'qq-resolver'
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 音乐搜索优先 v3,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 if (params.platform === 'migu') {
// 咪咕音乐平台:使用咪咕专用源
const miguSource = enabledSources.find((s) => s.id === 'migu')
if (miguSource) {
sourcesToTry = [miguSource]
} else {
throw new Error('未启用咪咕音乐音源')
}
} else {
// 网易云音乐平台(默认):优先使用内置官方接口,再回退 GD Studio。
const neteaseSources = enabledSources.filter((s) => s.id.includes('netease-backup'))
const gdStudioSource = enabledSources.find((s) => s.id === 'netease-gdstudio')
sourcesToTry = [...neteaseSources, ...(gdStudioSource ? [gdStudioSource] : [])]
console.log('网易云音乐平台搜索,优先使用内置接口,失败回退 GD Studio')
}
// 按选定的顺序尝试每个音源
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') {
// 音源 id → 平台 key 映射(仅对本功能支持的平台生效)
const platformOfSource = (id: string): string => {
if (id.includes('netease')) return 'netease'
if (id.includes('bilibili')) return 'bilibili'
if (id.includes('migu')) return 'migu'
return ''
}
const otherSources = enabledSources.filter(
(s) =>
s.id !== 'vkeys-v3' &&
s.id !== 'vkeys' &&
(platformOfSource(s.id)
? globalEnabledPlatforms.value.includes(platformOfSource(s.id))
: true)
)
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') {
if ((params.platform || 'tencent') !== '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 = transformVkeysV3TencentSearch
} 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 if (source.id === 'netease-gdstudio') {
url = `${source.baseUrl}?types=search&source=netease&name=${encodeURIComponent(params.keywords)}&count=${params.limit || 30}`
transformResponse = (data: any) => {
if (!Array.isArray(data)) {
throw new Error('GD Studio 搜索响应无效')
}
return data.map((item: any) => ({
id: item.id,
title: item.name,
artist: Array.isArray(item.artist) ? item.artist.join('/') : item.artist || '未知艺术家',
// GD Studio 搜索结果可能只返回 pic_id 而非完整 URL,非 URL 值不能当封面使用
cover: typeof item.pic === 'string' && item.pic.startsWith('http') ? item.pic : null,
album: item.album,
albumId: item.album_id,
duration: 0,
musicPlatform: 'netease',
musicId: item.id?.toString(),
sourceInfo: {
source: 'netease-gdstudio',
originalId: item.id?.toString(),
fetchedAt: new Date()
}
}))
}
} else if (source.id === 'migu') {
// 咪咕音乐使用 Native API 搜索
const page = Math.floor((params.offset || 0) / (params.limit || 30)) + 1
url = `/api/native-api/search/mg?str=${encodeURIComponent(params.keywords)}&page=${page}&limit=${params.limit || 30}`
transformResponse = (data: any) => {
if (!data || !data.list) {
return []
}
return data.list.map((item: any) => ({
id: item.songmid,
title: item.name,
artist: item.singer?.replace(/、/g, '/') || '未知艺术家',
cover: item.img,
album: item.albumName,
albumId: item.albumId,
duration: item.duration, //秒
musicPlatform: 'migu',
musicId: item.songmid,
sourceInfo: {
source: 'migu',
originalId: item.songmid,
fetchedAt: new Date(),
quality: item._types,
types: item.types
}
}))
}
} 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 请求使用简单请求,不携带任何会触发预检的自定义头
response = await $fetch(finalUrl, {
timeout: source.timeout || config.value.timeout,
signal,
headers: source.headers
})
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: 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}`, { cause: error })
}
}
/**
* 获取歌曲详情
*/
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
const fetchOptions: any = {
timeout: source.timeout || config.value.timeout,
headers: {
'Content-Type': 'application/json',
...source.headers
}
}
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 if (source.id === 'netease-gdstudio') {
url = `${source.baseUrl}?types=search&source=netease&name=${encodeURIComponent(ids)}&count=1`
transformResponse = (response: any) => {
if (!Array.isArray(response) || response.length === 0) {
throw new Error('GD Studio 未返回歌曲详情')
}
const data = response[0]
return [
{
id: data.id,
title: data.name,
artist: Array.isArray(data.artist)
? data.artist.join('/')
: data.artist || '未知艺术家',
// GD Studio 详情结果可能只返回 pic_id 而非完整 URL,非 URL 值不能当封面使用
cover: typeof data.pic === 'string' && data.pic.startsWith('http') ? data.pic : null,
album: data.album,
duration: 0,
musicPlatform: 'netease',
musicId: data.id?.toString(),
sourceInfo: {
source: 'netease-gdstudio',
originalId: data.id?.toString(),
fetchedAt: new Date()
}
}
]
}
} else if (source.id === 'ygking-qq') {
throw new Error('Ygking QQ 音乐仅支持播放链接获取')
} 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, fetchOptions)
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
* 根据平台选择合适的音源:网易云优先内置接口;QQ音乐使用 vkeys。
*/
const getSongUrl = async (
id: number | string,
quality?: number,
platform?: string,
cookie?: string,
options?: {
unblock?: boolean
bilibiliCid?: string
excludeSources?: string[]
}
): Promise<{
success: boolean
url?: string
source?: 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 === 'migu') {
// 咪咕音乐平台:使用咪咕专用源
const miguSource = enabledSources.find((source) => source.id === 'migu')
if (miguSource) {
sourcesToTry.push({ source: miguSource, type: 'migu' as any })
}
} else if (platform === 'tencent') {
// QQ 音乐保留所有 Vkeys 路径,新增直连源仅作为最后回退。
const v3 = enabledSources.find((source) => source.id === 'vkeys-v3')
const ygkingSource = enabledSources.find((source) => source.id === 'ygking-qq')
const v2 = enabledSources.find((source) => source.id === 'vkeys')
if (v3) {
sourcesToTry.push({ source: v3, type: 'tencent' })
}
if (v2) {
sourcesToTry.push({ source: v2, type: 'tencent' })
}
if (ygkingSource) {
sourcesToTry.push({ source: ygkingSource, type: 'tencent' })
}
} else {
// 网易云音乐平台(默认)
const neteaseSource = enabledSources.find((source) => source.id.includes('netease-backup'))
const gdStudioSource = enabledSources.find((source) => source.id === 'netease-gdstudio')
const orderedSources = [neteaseSource, gdStudioSource]
for (const source of orderedSources) {
if (source) {
sourcesToTry.push({ source, type: 'netease' })
}
}
for (const source of enabledSources.filter((source) => source.id.startsWith('meting-'))) {
sourcesToTry.push({ source, type: 'netease' })
}
}
// 逐个尝试音源
for (const { source, type } of sourcesToTry) {
if (options?.excludeSources?.includes(source.id)) {
continue
}
try {
let url: string | null = null
if (source.id === 'bilibili') {
const result = await getBilibiliTrackUrl(idParam, options?.bilibiliCid)
url = result.url
} else if (source.id === 'migu') {
const miguQualityFlag =
quality === 2 ? 'HQ' : quality === 3 ? 'SQ' : quality === 4 ? 'ZQ24' : 'PQ'
try {
// 服务器位于海外时咪咕官方接口不可用,交由上层使用星海音源
if (isServerInChina.value === null) {
await checkServerLocation()
}
if (isServerInChina.value === false) {
throw new Error('服务器位于海外,咪咕播放链接改用第三方音源')
}
// 服务端以 PQ 取链后按 toneFlag 升级音质路径
const miguResponse: any = await $fetch('/api/native-api/migu/playurl', {
params: {
contentId: idParam,
toneFlag: miguQualityFlag
},
timeout: source.timeout || 10000
})
if (miguResponse?.success && miguResponse?.url) {
url = miguResponse.url
}
} catch (miguErr) {
console.warn('[getSongUrl] 咪咕播放链接获取失败:', miguErr)
}
} else if (source.id === 'netease-gdstudio') {
const response: any = await $fetch(source.baseUrl, {
params: { types: 'url', source: 'netease', id: idParam, br: 128 },
timeout: source.timeout || 10000
})
if (response?.url) {
url = String(response.url)
}
} else if (source.id.startsWith('meting-')) {
const songInfo = await getMetingSongInfo(idParam, source)
if (songInfo.success && songInfo.data?.url) {
url = String(songInfo.data.url)
} else {
const response = await fetch(
`${source.baseUrl}?server=netease&type=url&id=${encodeURIComponent(idParam)}`,
{ redirect: 'follow' }
)
if (response.ok && response.url) {
url = response.url
}
}
} else if (source.id === 'vkeys-v3') {
const v3IdParam = getVkeysIdParam('tencent', idParam)
const infoResponse: any = await $fetch(
`${source.baseUrl}/tencent/song/info?${v3IdParam.key}=${encodeURIComponent(v3IdParam.value)}`,
{ timeout: source.timeout || 8000 }
)
const qualityInfo = Array.isArray(infoResponse?.data?.qualityInfo)
? infoResponse.data.qualityInfo
: []
if (infoResponse?.code !== 0 || qualityInfo.length === 0) {
throw new Error('vkeys v3 未返回可用音质')
}
const targetQuality = Number.isNaN(requestedQuality) ? 8 : requestedQuality
const qualityCandidates = [...new Set([targetQuality, 8, 4, 10, 11, 14])]
const selectedQuality = qualityCandidates.find((candidate) =>
qualityInfo.some(
(item: any) => Number(item.type) === candidate && Number(item.size) > 0
)
)
const v2Source = enabledSources.find((item) => item.id === 'vkeys')
if (!v2Source || selectedQuality === undefined) {
throw new Error('vkeys v3 未找到可播放音质')
}
const v2Url = `${v2Source.baseUrl}/tencent?${v3IdParam.key}=${encodeURIComponent(v3IdParam.value)}&quality=${selectedQuality}`
const v2Response: any = await $fetch(v2Url, { timeout: v2Source.timeout || 8000 })
if (v2Response?.code === 200 && v2Response?.data?.url) {
url = String(v2Response.data.url)
}
} else if (source.id === 'ygking-qq') {
const qualityMap: Record<number, string> = {
4: '128',
8: '320',
10: 'flac',
11: 'master',
14: 'master'
}
const qualityKey = qualityMap[requestedQuality] || '320'
const response: any = await $fetch(source.baseUrl, {
params: { mid: idParam, quality: qualityKey },
timeout: source.timeout || 8000
})
if (response?.code === 0 && response?.data) {
const directUrl =
response.data[idParam] || response.data[Object.keys(response.data)[0]]
if (directUrl) url = String(directUrl)
}
} 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 vkeysIdParam = getVkeysIdParam(endpoint as 'netease' | 'tencent', idParam)
const vkeysUrl = `${source.baseUrl}/${endpoint}?${vkeysIdParam.key}=${encodeURIComponent(vkeysIdParam.value)}&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 {
// 网易云备用API
let neteaseQuality: number | null = null
// 优先使用传入的 quality 参数
if (quality !== undefined && quality !== null) {
neteaseQuality = Number(quality)
} else {
// 否则回退到全局设置
try {
const { useAudioQuality } = await import('./useAudioQuality')
const { getQuality } = 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, source: source.id }
} 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' | 'migu',
id: number | string,
meta?: LyricUpgradeMeta
): Promise<{
success: boolean
data?: { lrc: string; trans?: string; yrc?: string; ttml?: string }
error?: string
}> => {
return fetchLyricsWithoutUpgrade(platform, id, meta)
}
/**
* 转换 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 mid = song.mid || song.songmid || song.songMID || song.musicId || song.id
const originalSongId =
song.id && song.id.toString() !== mid?.toString() ? song.id.toString() : undefined
const transformedSong = {
id: mid?.toString(),
title: song.song || song.name || song.title,
artist: song.singer || song.artist || '未知艺术家',
cover: song.cover,
album: song.album,
albumId: song.albumId,
duration: song.duration || song.dt,
musicPlatform: 'tencent',
musicId: mid?.toString(),
url: song.url,
hasUrl: !!song.url,
sourceInfo: {
source: 'vkeys',
originalId: mid?.toString(),
originalSongId,
fetchedAt: new Date(),
mid,
vid: song.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,
albumId: song.albumId,
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
})
}
}
const transformVkeysV3TencentSearch = (response: any): any[] => {
if (response?.code !== 0 || !Array.isArray(response?.data?.list)) {
throw new Error(`vkeys v3 API 错误: ${response?.message || '响应无效'}`)
}
return response.data.list.map((item: any) => {
const mid = item.songMID ?? item.mid
const originalSongId = item.songID ?? item.songId ?? item.id
const id = mid ?? originalSongId
return {
id: id?.toString(),
title: item.title,
artist: Array.isArray(item.singerList)
? item.singerList.map((singer: any) => singer.name).join('/')
: item.singer || '',
cover: item.albumImage || item.cover || '',
album: item.album || '',
albumId: item.albumID ?? item.albumId,
duration: item.interval ?? item.duration,
musicPlatform: 'tencent',
musicId: id?.toString(),
url: undefined,
hasUrl: false,
sourceInfo: {
source: 'vkeys-v3',
originalId: id?.toString(),
originalSongId: originalSongId?.toString(),
fetchedAt: new Date(),
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 albumId: string | number | 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
albumId = item.al?.id
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
albumId = item.album?.id || detail?.al?.id
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,
albumId,
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),
isServerInChina: readonly(isServerInChina),
// 计算属性
sourceStatusSummary: getSourceStatusSummary,
currentSourceInfo: getCurrentSourceInfo,
// 方法
searchSongs,
getSongDetail,
getSongUrl,
getLyrics,
getMetingSongInfo,
updateSourceStatus,
validatePlayUrl,
getDjPrograms,
checkServerLocation
}
}