初次提交
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* 音源管理器 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user