/** * 音源管理器 Composable * 提供多音源搜索、故障转移和状态监控功能 */ import { getEnabledSources, getSourceById, MUSIC_SOURCE_CONFIG, type MusicSearchParams, type MusicSearchResult, type MusicSource, type MusicSourceConfig, type SongDetailParams, type SongDetailResult, type SourceStatus } from '~/utils/musicSources' /** * 音源管理器 Composable */ export const useMusicSources = () => { // 配置 const config = ref(MUSIC_SOURCE_CONFIG) // 当前使用的音源 const currentSource = ref(config.value.primarySource) // 音源状态 const sourceStatus = ref>({}) /** * 验证播放链接的有效性 * @param url 播放链接 * @returns Promise<{valid: boolean, duration?: number, error?: string}> */ const validatePlayUrl = async ( url: string ): Promise<{ valid: boolean duration?: number error?: string }> => { try { // 修复HTTP/HTTPS协议问题 let validatedUrl = url if (url.startsWith('http://')) { validatedUrl = url.replace('http://', 'https://') } // 使用HEAD请求检查链接可用性,避免下载整个文件 const response = await fetch(validatedUrl, { method: 'HEAD', signal: AbortSignal.timeout(5000) // 5秒超时 }) if (!response.ok) { return { valid: false, error: `HTTP ${response.status}: ${response.statusText}` } } // 检查Content-Type是否为音频文件 const contentType = response.headers.get('content-type') if (contentType && !contentType.includes('audio') && !contentType.includes('video')) { return { valid: false, error: `不是音频文件: ${contentType}` } } // 尝试获取文件大小来估算时长 const contentLength = response.headers.get('content-length') let estimatedDuration = 0 if (contentLength) { const fileSizeBytes = parseInt(contentLength) // 粗略估算:假设128kbps的音质,1MB约为64秒 estimatedDuration = (fileSizeBytes / 1024 / 1024) * 64 if (estimatedDuration < 5) { return { valid: false, duration: estimatedDuration, error: `歌曲时长过短: ${estimatedDuration.toFixed(1)}秒` } } } return { valid: true, duration: estimatedDuration } } catch (error: any) { return { valid: false, error: error.message || '链接验证失败' } } } // 是否正在搜索 const isSearching = ref(false) // 最后一次搜索的音源 const lastUsedSource = ref('') // 服务器是否在中国 const isServerInChina = ref(null) // 检测服务器位置 const checkServerLocation = async () => { if (isServerInChina.value !== null) return try { const data = await $fetch('/api/system/location') if (data && data.success) { isServerInChina.value = data.data.isInChina console.log(`[useMusicSources] 服务器位置检测: ${isServerInChina.value ? '中国' : '海外'}`) } } catch (e) { console.warn('[useMusicSources] 服务器位置检测失败,默认为海外:', e) isServerInChina.value = false } } /** * 搜索歌曲(带故障转移) */ const searchSongs = async ( params: MusicSearchParams, signal?: AbortSignal ): Promise => { if (signal?.aborted) { throw new DOMException('搜索请求已被取消', 'AbortError') } isSearching.value = true try { const url = `/api/music/kg/search?keyword=${encodeURIComponent(params.keywords)}` const response: any = await $fetch(url, { signal }) if (!response || !response.data || !response.data.info) { throw new Error('获取酷狗搜索结果失败') } const results = response.data.info.map((item: any) => ({ id: item.hash, title: item.songname, artist: item.singername, cover: item.pic || '', album: item.album_name || '', duration: item.duration * 1000, // Assuming duration is in seconds musicPlatform: 'kugou', musicId: item.hash, hasUrl: true, isVip: item.isVip, sourceInfo: { source: 'kugou', originalId: item.hash, fetchedAt: new Date() } })) currentSource.value = 'kugou' lastUsedSource.value = 'kugou' return { success: true, source: 'kugou', data: results, error: undefined } } catch (error: any) { return { success: false, source: 'kugou', data: [], error: error.message || '搜索失败' } } finally { isSearching.value = false } } /** * 获取歌曲详情 */ const getSongDetail = async (params: SongDetailParams): Promise => { // For Kugou, we don't have a specific detail endpoint in our proxy yet, // and usually the search result provides enough info or we can just return empty detail. // To satisfy the interface, we return a mock or basic info. return { success: true, source: 'kugou', data: { songs: [] }, error: undefined } } /** * 更新音源状态 */ const updateSourceStatus = ( sourceId: string, status: SourceStatus['status'], errorMessage?: string, responseTime?: number ) => { sourceStatus.value[sourceId] = { status, responseTime, lastCheck: new Date(), errorMessage } } /** * 获取歌曲播放URL */ const getSongUrl = async ( id: number | string, quality?: number, platform?: string, cookie?: string, options?: { unblock?: boolean } ): Promise<{ success: boolean url?: string error?: string }> => { try { const hashParam = Array.isArray(id) ? id[0] : id.toString() // Fetch play URL via Kugou proxy const response: any = await $fetch(`/api/music/kg/playurl?hash=${hashParam}`) if (response && response.data && response.data.play_url) { const playUrl = response.data.play_url // Skip HEAD validation for Kugou URLs as they often fail HEAD requests or CORS return { success: true, url: playUrl } } return { success: false, error: '无法获取有效的播放链接' } } catch (error: any) { return { success: false, error: error?.message || '获取播放链接失败' } } } /** * 获取歌词 */ const getLyrics = async ( platform: string, id: number | string ): Promise<{ success: boolean data?: { lrc: string; trans?: string; yrc?: string; ttml?: string } error?: string }> => { return { success: false, error: '歌词获取功能正在重构' } } /** * 获取音源状态摘要 */ 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' } }) return { // 状态 config: readonly(config), currentSource: readonly(currentSource), sourceStatus: readonly(sourceStatus), isSearching: readonly(isSearching), lastUsedSource: readonly(lastUsedSource), // 计算属性 sourceStatusSummary: getSourceStatusSummary, currentSourceInfo: getCurrentSourceInfo, // 方法 searchSongs, getSongDetail, getSongUrl, getLyrics, updateSourceStatus, validatePlayUrl } }