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

327 lines
8.1 KiB
TypeScript

/**
* 音源管理器 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<MusicSourceConfig>(MUSIC_SOURCE_CONFIG)
// 当前使用的音源
const currentSource = ref<string>(config.value.primarySource)
// 音源状态
const sourceStatus = ref<Record<string, SourceStatus>>({})
/**
* 验证播放链接的有效性
* @param url 播放链接
* @returns Promise<{valid: boolean, duration?: number, error?: string}>
*/
const validatePlayUrl = async (
url: string
): Promise<{
valid: boolean
duration?: number
error?: string
}> => {
try {
// 修复HTTP/HTTPS协议问题
let validatedUrl = url
if (url.startsWith('http://')) {
validatedUrl = url.replace('http://', 'https://')
}
// 使用HEAD请求检查链接可用性,避免下载整个文件
const response = await fetch(validatedUrl, {
method: 'HEAD',
signal: AbortSignal.timeout(5000) // 5秒超时
})
if (!response.ok) {
return {
valid: false,
error: `HTTP ${response.status}: ${response.statusText}`
}
}
// 检查Content-Type是否为音频文件
const contentType = response.headers.get('content-type')
if (contentType && !contentType.includes('audio') && !contentType.includes('video')) {
return {
valid: false,
error: `不是音频文件: ${contentType}`
}
}
// 尝试获取文件大小来估算时长
const contentLength = response.headers.get('content-length')
let estimatedDuration = 0
if (contentLength) {
const fileSizeBytes = parseInt(contentLength)
// 粗略估算:假设128kbps的音质,1MB约为64秒
estimatedDuration = (fileSizeBytes / 1024 / 1024) * 64
if (estimatedDuration < 5) {
return {
valid: false,
duration: estimatedDuration,
error: `歌曲时长过短: ${estimatedDuration.toFixed(1)}秒`
}
}
}
return {
valid: true,
duration: estimatedDuration
}
} catch (error: any) {
return {
valid: false,
error: error.message || '链接验证失败'
}
}
}
// 是否正在搜索
const isSearching = ref(false)
// 最后一次搜索的音源
const lastUsedSource = ref<string>('')
// 服务器是否在中国
const isServerInChina = ref<boolean | null>(null)
// 检测服务器位置
const checkServerLocation = async () => {
if (isServerInChina.value !== null) return
try {
const data = await $fetch('/api/system/location')
if (data && data.success) {
isServerInChina.value = data.data.isInChina
console.log(`[useMusicSources] 服务器位置检测: ${isServerInChina.value ? '中国' : '海外'}`)
}
} catch (e) {
console.warn('[useMusicSources] 服务器位置检测失败,默认为海外:', e)
isServerInChina.value = false
}
}
/**
* 搜索歌曲(带故障转移)
*/
const searchSongs = async (
params: MusicSearchParams,
signal?: AbortSignal
): Promise<MusicSearchResult> => {
if (signal?.aborted) {
throw new DOMException('搜索请求已被取消', 'AbortError')
}
isSearching.value = true
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<SongDetailResult> => {
// 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
}
}