/** * 音源管理器 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>() const lyricProgressSubscribers = new Map>() const LYRIC_CACHE_TTL = 60 * 1000 // 服务器位置检测(模块级单例,避免多次实例化导致重复请求) const globalIsServerInChina = ref(null) let locationCheckPromise: Promise | 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 = { 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() 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(MUSIC_SOURCE_CONFIG) // 当前使用的音源 const currentSource = ref(config.value.primarySource) // 音源状态 const sourceStatus = ref>({}) // 平台启用配置(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 => { 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() 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(' { 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((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('') /** * 使用 Native Music API 搜索 */ const searchNativeMusic = async (params: MusicSearchParams): Promise => { 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 => { // 检查请求是否已被取消 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 => { 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 => { 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 => { 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 => { 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 = { 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 => { 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 } }