327 lines
8.1 KiB
TypeScript
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
|
|
}
|
|
}
|