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 规范化,
与新增平台的注册表改动分开,出问题时便于单独回滚。
This commit is contained in:
snowy-yang committed 2026-10-03 00:26:51 +08:00
1 parent 33b96f92c4
commit 68e071b166
20 files changed
+2218 -33

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
// QQ音乐链接解析(上游 music-lib/qq/song.go 的 Parse 移植)
// 歌曲链接形态:
// https://y.qq.com/n/ryqq/songDetail/<songmid>
// https://y.qq.com/n/yqq/song/<songmid>.html
// https://i.y.qq.com/v8/playsong.html?songid=<数字 id>(先反查 songmid)
// ...?songmid=<songmid>
// 返回 Song(含 songmid);直链 url/type 为尽力附带,失败不影响元数据返回(与上游一致)。
import { httpFetch } from '../request.ts'
import { formatPlayTime, sizeFormate } from '../index.ts'
import { formatSingerName } from '../utils.ts'
import txApi from '../apis/tx.ts'
import { parseAlbumLink } from './album.ts'
import { parsePlaylistLink } from './playlist.ts'
const SONG_DETAIL_RE = /songDetail\/([A-Za-z0-9]+)/
const SONG_PATH_RE = /\/song\/([A-Za-z0-9]+)/
const SONG_MID_QUERY_RE = /[?&]songmid=([A-Za-z0-9]+)/
const SONG_ID_QUERY_RE = /[?&]songid=(\d+)/
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1'
/** 上游 fetchSongDetailByID:数值 songid → mid */
const fetchMidBySongId = async(songid: string) => {
const { statusCode, body } = await httpFetch(
`https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg?songid=${encodeURIComponent(songid)}&format=json`,
{ headers: { 'User-Agent': UA, Referer: 'http://m.y.qq.com' }, timeout: 15000 },
).promise
const mid = statusCode === 200 ? body?.data?.[0]?.mid : ''
if (!mid) throw new Error('QQ音乐歌曲不存在')
return String(mid)
}
/** 上游 fetchSongDetail:songmid → 原始条目(顺带拿 file 体积信息) */
const fetchSongDetailRaw = async(songmid: string) => {
const { statusCode, body } = await httpFetch(
`https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg?songmid=${encodeURIComponent(songmid)}&format=json`,
{ headers: { 'User-Agent': UA, Referer: 'http://m.y.qq.com' }, timeout: 15000 },
).promise
const item = statusCode === 200 ? body?.data?.[0] : null
if (!item?.mid) throw new Error('QQ音乐歌曲不存在')
return item
}
/** 原始条目 → MusicServer Song 形状 */
const convertSong = (item: any) => {
const file = item?.file ?? {}
const types: any[] = []
const _types: Record<string, { size: string }> = {}
const push = (type: string, size: any) => {
if (typeof size === 'number' && size > 0) {
const formatted = sizeFormate(size)
types.push({ type, size: formatted })
_types[type] = { size: formatted }
}
}
push('128k', file.size_128mp3)
push('320k', file.size_320mp3)
push('flac', file.size_flac)
push('flac24bit', file.size_hires)
const albumMid = item?.album?.mid ?? ''
const singer = formatSingerName(item?.singer, 'name')
return {
source: 'tx' as const,
songmid: String(item.mid),
songId: item.id,
name: item.title ?? item.name ?? '',
singer,
albumId: albumMid,
albumMid,
albumName: item?.album?.name ?? '',
interval: formatPlayTime(item?.interval ?? 0),
img: albumMid ? `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumMid}.jpg` : '',
lrc: null,
strMediaMid: file.media_mid ?? item.mid,
types,
_types,
typeUrl: {},
}
}
/** 上游 Parse:歌曲链接 → Song(尽力附带直链 url/type) */
export const parseSong = async(link: string) => {
const raw = String(link ?? '')
let songmid = SONG_DETAIL_RE.exec(raw)?.[1] ?? SONG_PATH_RE.exec(raw)?.[1] ?? ''
let songid = ''
if (!songmid) {
songid = SONG_ID_QUERY_RE.exec(raw)?.[1] ?? ''
songmid = SONG_MID_QUERY_RE.exec(raw)?.[1] ?? ''
if (!songmid && songid) songmid = await fetchMidBySongId(songid)
}
if (!songmid) throw new Error('无效的QQ音乐歌曲链接')
const item = await fetchSongDetailRaw(songmid)
const song: any = convertSong(item)
if (songid) song.songId = Number(songid)
try {
const hit = await txApi.getMusicUrl(song, '320k').promise
song.url = hit.url
song.type = hit.type
} catch {
// 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
}
return song
}
export const parseAlbum = async(link: string) => parseAlbumLink(link)
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
export default { parseSong, parseAlbum, parsePlaylist }