Files
MusicServer/sdk/kg/parse.ts
T
snowy-yang 68e071b166 feat(sdk): kg/kw/tx/wy 补齐专辑、歌单与 parse 能力
四个主流平台从「搜索 + 取流」补到能力对齐,新增 playlist/parse
(tx、wy 另加 userPlaylist),顺带修掉酷我直链的一个真实缺陷。

- kw:酷我 CDN 回吐的 query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3…`),
  `$` 非 URL 标准分隔符,播放器与解析器普遍不认,导致拿到直链也放不出声;
  统一还原成 `=` 再对外返回
- wy:补齐 album/playlist/parse/userPlaylist,与 kg/tx 能力对齐
- kg/kw/tx:接入 parse(跨平台精确匹配的输入源)与歌单

为什么单独一次提交:这批改动不动架构,只做能力补齐与 URL 规范化,
与新增平台的注册表改动分开,出问题时便于单独回滚。
2026-10-03 00:26:51 +08:00

112 lines
4.5 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.
// 酷狗链接解析
// 歌曲:https://www.kugou.com/song/#hash=<32位hash>(含 &album_id=... 等尾串)、裸 hash
// 上游对应:music-lib/kugou/song.go Parse / kugou.go fetchSongInfo
// 元数据 m.kugou.com/app/i/getSongInfo.php(songName/author_name/timeLength/album_img/bitRate)
// 另尽力合并 musicInfo.getMusicInfo 的专辑/多档 hash,直链取流失败不影响返回(上游为必得 URL)。
import { httpFetch } from '../request.ts'
import { formatPlayTime, decodeName } from '../index.ts'
import kgApi from '../apis/kg.ts'
import { getMusicInfo } from './musicInfo.ts'
import album from './album.ts'
import playlist from './playlist.ts'
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
const HASH_RE = /hash=([a-fA-F0-9]{32})/i
const RAW_HASH_RE = /^[a-fA-F0-9]{32}$/
/** 链接/裸 hash → 32 位小写 hash(上游 Parse 的 `(?i)hash=([a-f0-9]{32})`,另接受裸 hash) */
export const extractHash = (link: any): string => {
const raw = String(link ?? '').trim()
const match = raw.match(HASH_RE)
if (match) return match[1]!.toLowerCase()
return RAW_HASH_RE.test(raw) ? raw.toLowerCase() : ''
}
/** 上游 fetchSongInfo:m.kugou.com playInfo(返回元数据 + 直链,付费曲无 url) */
const fetchSongInfoPhp = async(hash: string) => {
const { statusCode, body } = await httpFetch(`http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash=${hash}`, {
method: 'get',
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
timeout: 15000,
}).promise
if (statusCode !== 200 || typeof body !== 'object' || body == null) return null
if (Number(body.errcode ?? 0) !== 0 || !body.hash) return null
return body
}
/** 上游 Parse:酷狗歌曲链接(或裸 hash)→ Song,尽力附带直链 */
export const parseSong = async(link: string) => {
const hash = extractHash(link)
if (!hash) throw new Error('无效的酷狗歌曲链接(未找到 32 位 hash)')
let [raw, info] = await Promise.all([
fetchSongInfoPhp(hash).catch(() => null),
getMusicInfo(hash).catch(() => undefined),
])
if (!raw && !info) throw new Error('酷狗歌曲不存在或已下架')
const fileName = String(raw?.fileName ?? '')
const name = decodeName(String(raw?.songName ?? '').trim() || fileName.split(' - ').slice(1).join(' - ') || info?.name || '')
const singer = decodeName(String(raw?.author_name ?? '').trim() || info?.singer || '')
const duration = Number(raw?.timeLength ?? 0) || 0
const song: any = {
songmid: String(info?.songmid ?? raw?.audio_id ?? '').trim(),
hash: String(raw?.hash ?? hash).toLowerCase(),
name,
singer,
albumName: decodeName(String(info?.albumName ?? '').trim()),
albumId: String(info?.albumId ?? raw?.albumid ?? raw?.req_albumid ?? '').trim(),
interval: duration > 0 ? formatPlayTime(duration) : (info?.interval ?? '--/--'),
img: String(raw?.album_img ?? '').replace('{size}', '240') || info?.img || null,
lrc: null,
otherSource: null,
types: info?.types ?? [],
_types: info?._types ?? {},
typeUrl: {},
source: 'kg' as const,
extra: {
hash: String(raw?.hash ?? hash).toLowerCase(),
url: String(raw?.url ?? ''),
backup_url: raw?.backup_url ?? [],
bitrate: Number(raw?.bitRate ?? 0) || 0,
link: `https://www.kugou.com/song/#hash=${hash}`,
},
}
// 上游 fetchSongInfo 必得 URL:playInfo 的 url/backup_url 优先(官方直链,付费曲为空)
if (!song.url) {
const rawUrl = String(raw?.url ?? '') || String(raw?.backup_url?.[0] ?? '')
if (rawUrl) {
song.url = rawUrl
song.type = Number(raw?.bitRate ?? 0) >= 320000 ? '320k' : '128k'
}
}
// 仍无直链时(风控/付费)用 apis/kg 的签名通道尽力补一次;失败仅返回元数据
if (!song.url) {
try {
const hit = await kgApi.getMusicUrl({
hash: song.hash,
albumId: song.albumId,
songmid: song.songmid,
_types: song._types,
} as any, '128k').promise
if (hit?.url) {
song.url = hit.url
song.type = hit.type
}
} catch {
// 忽略:与上游付费曲无直链的失败语义一致(元数据仍返回)
}
}
return song
}
export const parseAlbum = async(link: string) => album.parseAlbumLink(link)
export const parsePlaylist = async(link: string) => playlist.parsePlaylistLink(link)
export default { parseSong, parseAlbum, parsePlaylist }