Files
BYW-VoiceHub/app/composables/useChkszSource.ts
T

214 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ref, watch } from 'vue'
// ChKSz 音源(api.chksz.com):用户自配 apikey,前端直连(接口返回 Access-Control-Allow-Origin: *)
// 仅支持 netease / tencent 平台,接口免费配额有限(约 400 次 + 每分钟 20 次),需缓存与限流
const CHKSZ_BASE_URL = 'https://api.chksz.com'
const STORAGE_KEY = 'chksz_api_key'
// 播放链接缓存(5 分钟,chksz 返回的直链有时效)
const CHKSZ_CACHE_TTL = 5 * 60 * 1000
// 滑动窗口限流:60 秒内最多 18 次,留余量规避接口 20 次/分钟限制
const CHKSZ_RATE_LIMIT_MAX = 18
const CHKSZ_RATE_LIMIT_WINDOW = 60 * 1000
// 项目音质值 → chksz 接口参数
// 网易 level:standard/exhigh/lossless/hires/jymaster
const NETEASE_LEVEL_MAP: Record<number, string> = {
2: 'standard',
4: 'exhigh',
5: 'lossless',
6: 'hires',
9: 'jymaster'
}
// QQ size:128k/320k/flac/hires/master
const TENCENT_SIZE_MAP: Record<number, string> = {
4: '128k',
8: '320k',
10: 'flac',
11: 'hires',
14: 'master'
}
const chkszUrlCache = new Map<string, { url: string; expireAt: number }>()
// 同 key 请求合并,避免并发重复请求
const chkszInflight = new Map<string, Promise<string | null>>()
// 最近请求时间戳(滑动窗口限流)
const chkszRequestTimes: number[] = []
const readStoredKey = (): string => {
if (typeof window === 'undefined') return ''
try {
return localStorage.getItem(STORAGE_KEY) || ''
} catch {
return ''
}
}
let globalChkszKey: any = null
export function useChkszSource() {
if (!globalChkszKey) {
globalChkszKey = ref(readStoredKey())
if (import.meta.client) {
watch(
globalChkszKey,
(newValue) => {
try {
if (newValue && newValue.trim()) {
localStorage.setItem(STORAGE_KEY, newValue.trim())
} else {
localStorage.removeItem(STORAGE_KEY)
}
} catch (error) {
console.error('[chksz] Failed to save apikey:', error)
}
},
{ deep: true }
)
}
}
const chkszApiKey = globalChkszKey
const hasChkszKey = () => {
const key = chkszApiKey.value
return !!(key && key.trim())
}
const clearChkszKey = () => {
chkszApiKey.value = ''
}
const checkRateLimit = (): boolean => {
const now = Date.now()
const windowStart = now - CHKSZ_RATE_LIMIT_WINDOW
while (chkszRequestTimes.length > 0 && chkszRequestTimes[0] < windowStart) {
chkszRequestTimes.shift()
}
if (chkszRequestTimes.length >= CHKSZ_RATE_LIMIT_MAX) {
return false
}
chkszRequestTimes.push(now)
return true
}
/**
* 通过 ChKSz 获取播放链接
* @returns 播放 URL;未配 key / 不支持的平台音质 / 接口失败均返回 null(走降级链)
*/
const getChkszMusicUrl = async (
platform: string,
musicId: string | number,
quality: number | string
): Promise<string | null> => {
if (!hasChkszKey()) return null
const apiKey = chkszApiKey.value.trim()
const normalizedQuality = Number(quality)
let endpoint = ''
let qualityParam = ''
if (platform === 'netease') {
const level = NETEASE_LEVEL_MAP[normalizedQuality]
if (!level) return null
endpoint = '163_music'
qualityParam = `level=${level}`
} else if (platform === 'tencent') {
const size = TENCENT_SIZE_MAP[normalizedQuality]
if (!size) return null
endpoint = 'qq_music'
qualityParam = `size=${size}&type=json`
} else {
return null
}
const cacheKey = `${platform}:${musicId}:${qualityParam}`
const cached = chkszUrlCache.get(cacheKey)
if (cached && cached.expireAt > Date.now()) {
return cached.url
}
const now = Date.now()
for (const [key, entry] of chkszUrlCache) {
if (entry.expireAt <= now) {
chkszUrlCache.delete(key)
}
}
const inflight = chkszInflight.get(cacheKey)
if (inflight) {
return inflight
}
if (!checkRateLimit()) {
console.warn('[chksz] 请求过于频繁,跳过本次请求')
return null
}
const promise = (async (): Promise<string | null> => {
try {
const apiUrl =
`${CHKSZ_BASE_URL}/api/${endpoint}?` +
`${platform === 'netease' ? 'id' : 'mid'}=${encodeURIComponent(String(musicId))}&` +
`${qualityParam}&apikey=${encodeURIComponent(apiKey)}`
const response = await fetch(apiUrl, { signal: AbortSignal.timeout(8000) })
if (!response.ok) return null
const data: any = await response.json()
const url = platform === 'netease' ? data?.data?.url : data?.url
if (data?.code === 200 && url && /^https?:\/\//i.test(String(url))) {
const finalUrl = String(url)
chkszUrlCache.set(cacheKey, { url: finalUrl, expireAt: Date.now() + CHKSZ_CACHE_TTL })
return finalUrl
}
} catch (error) {
console.warn('[chksz] 获取播放链接失败:', error)
}
return null
})()
chkszInflight.set(cacheKey, promise)
promise.finally(() => {
chkszInflight.delete(cacheKey)
})
return promise
}
/**
* 验证 apikey 有效性(消耗 1 次接口配额)
*/
const verifyChkszKey = async (
key?: string
): Promise<{ valid: boolean; message: string; detail?: string }> => {
const apiKey = (key !== undefined ? key : chkszApiKey.value).trim()
if (!apiKey) {
return { valid: false, message: 'empty' }
}
try {
const response = await fetch(
`${CHKSZ_BASE_URL}/api/163_music?id=347230&level=standard&apikey=${encodeURIComponent(apiKey)}`,
{ signal: AbortSignal.timeout(8000) }
)
if (!response.ok) {
return { valid: false, message: `http_${response.status}` }
}
const data: any = await response.json()
if (data?.code === 200 && data?.data?.url) {
return { valid: true, message: 'ok' }
}
// key 无效(401/403 语义)或配额耗尽时接口返回业务错误码
return { valid: false, message: 'api_error', detail: data?.msg }
} catch (error: any) {
return { valid: false, message: error?.name === 'TimeoutError' ? 'timeout' : 'network' }
}
}
return {
chkszApiKey,
hasChkszKey,
clearChkszKey,
getChkszMusicUrl,
verifyChkszKey
}
}