feat(sdk): 新增咪咕/千千/JOOX/哔哩哔哩/5sing/Apple Music 六平台

平台注册表由 9 个扩到 15 个,同时补齐 sdk/types.ts 的类型与
OpenAPI 文档里的能力位声明。

- migu:命中 Z3D/I3D 加密候选时不返回直链,改由服务端解密代理出流,
  SDK 侧只负责登记票据,避免客户端拿到无法播放的密文
- apple:只提供 preview 试听片段(完整曲为 DRM),标 noAggregate
  排除在跨平台精确匹配之外,防止用它顶掉其它平台的可播结果
- 其余平台按既有 PlatformModule 约定实现搜索/专辑/歌单/歌词

为什么合并成一次提交:六平台共用同一份注册表与能力位声明,
拆开会产生互相引用、单独无法通过类型检查的中间态。
This commit is contained in:
snowy-yang committed 2026-10-03 00:26:32 +08:00
1 parent 47ce651f46
commit 33b96f92c4
54 files changed
+6161 -5

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
// 5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块
// 上游 music-lib/fivesing 移植(平台 id 统一为 '5sing',上游 model.Source 为 "fivesing")。
// 能力:搜索(原创 yc / 翻唱 fc / 伴奏 bz,统一种类字段 songType)/ 取流(明文直链,档位 sq→hq→lq)/
// 歌词(newget.dynamicWords)/ 歌单(搜索、详情、链接解析)/ 单曲链接解析。
// 不实现:专辑、封面(上游无 GetPic)、分类歌单/个人歌单(上游显式 not supported,保留抛错语义)、
// 榜单/热搜/歌手/评论(上游均无)。
// 取流入口在 sdk/apis/5sing.ts;songmid 约定 "<songId>|<songType>"(如 "932557|yc",与上游 ID 一致)。
// 登录:上游 New(cookie) 的 cookie 会随请求发送(付费/受限内容可能受益);
// 本移植从 sdk/auth/store.ts 的 getCredentials('5sing') 读取 cookie(无则匿名,匿名实测可搜索/取流/歌词/歌单)。
import musicSearch from './musicSearch.ts'
import lyric from './lyric.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import fivesingApi from '../apis/5sing.ts'
import { buildSongLink, extractSongId, getStatus } from './util.ts'
const fivesing = {
musicSearch,
getMusicUrl(songInfo, type) {
return fivesingApi.getMusicUrl(songInfo, type)
},
getLyric(songInfo, ...rest) {
return lyric.getLyric(songInfo, ...rest)
},
playlist,
parse,
getMusicDetailPageUrl(songInfo) {
if (songInfo?.link) return String(songInfo.link)
const { songID, songType } = extractSongId(songInfo?.songmid ?? songInfo?.id, songInfo?.songType)
if (!songID || !songType) return 'http://5sing.kugou.com/'
return buildSongLink(songType, songID)
},
// 上游 GetUserPlaylists 显式返回 not supported,这里保留同样语义
userPlaylist: {
async getList(_page?: number, _limit?: number): Promise<any> {
throw new Error('user playlists not supported')
},
},
}
/** 登录状态(凭据存在性判断;5sing 上游无扫码/账密登录,凭据只能手工写入 data/auth.json) */
export const status = getStatus
export default fivesing
+38
View File
@@ -0,0 +1,38 @@
// 5sing 歌词(上游 fivesing/lyric.go 的 GetLyrics 移植)
// 接口: GET http://mobileapi.5sing.kugou.com/song/newget?songid=&songtype=
// → data.dynamicWords 即 LRC 文本(实测为 "[00:31.76]..." 格式,逐字/滚动歌词同源)。
// 上游 ID 约定:model.Song.ID = "<songid>|<songtype>",Extra.songid/songtype 优先;
// 本移植从 songmid 的 "|" 拆分(路由层只透传 songmid),也接受 songInfo.songType 字段。
// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric: '' }(5sing 无翻译歌词)。
import { isForeignSource, extractSongId } from './util.ts'
import { fetchDynamicLyric } from './musicInfo.ts'
/** 非取消句柄版(测试/内部用):直接返回歌词文本 */
export const getLyricText = async(songID: string, songType: string): Promise<string> => {
const lyric = await fetchDynamicLyric(songID, songType)
if (!lyric) throw new Error('lyrics not found')
return lyric
}
export const getLyric = (songInfo: any, ..._rest: any[]) => {
const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} }
let current: any = null
requestObj.cancelHttp = () => {
current?.cancelHttp?.()
current = null
}
requestObj.promise = (async() => {
if (isForeignSource(songInfo)) throw new Error('source mismatch')
const { songID, songType } = extractSongId(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid, songInfo?.songType)
if (!songID) throw new Error('invalid id')
const lyric = await fetchDynamicLyric(songID, songType, (request) => { current = request })
if (!lyric) throw new Error('lyrics not found')
return { lyric, tlyric: '' }
})()
return requestObj
}
export default { getLyric, getLyricText }
+114
View File
@@ -0,0 +1,114 @@
// 5sing 歌曲信息 / 取流底层(上游 fivesing/fivesing.go 的 fetchSongInfo / fetchAudioLink 移植)
//
// 链路(实测 2026-10-02 全部可用,无需签名/base64 参数——上游 fivesing 包内没有 _sign/base64 逻辑,
// 仅 playlist 搜索的封面等是 CDN 地址;取流为明文 URL 直出):
// 取流: GET {MOBILE_API}/song/getSongUrl?songid=&songtype= → data.{sq,hq,lq}url(_backup),code 必须 1000
// 详情: GET {MOBILE_API}/song/newget?songid=&songtype= → data.SN / data.user.NN / data.user.I / data.dynamicWords
// 档位字段:squrl(无损/高码)→ hqurl(高)→ lqurl(低),每档有 _backup 备用地址(上游 getFirstValid 取首个非空)。
// 上游固定 sq→hq→lq;本移植按请求档位微调(请求 64k/lq 时倒序),返回实际命中档位。
import { MOBILE_API, fetch5singJson, getFirstValid, kindName, estimateDuration, buildSongLink } from './util.ts'
export type AudioHit = { type: string, url: string }
const QUALITY_TIERS: Array<'sq' | 'hq' | 'lq'> = ['sq', 'hq', 'lq']
/** data 里某档位的可用地址(主地址 → backup,上游 getFirstValid 语义) */
const tierUrl = (data: any, tier: string): string =>
getFirstValid(data?.[`${tier}url`], data?.[`${tier}url_backup`])
/**
* 上游 fetchAudioLink:仅获取音频链接。
* register 用于把当前 httpFetch 对象交给调用方(cancelHttp)。
* preferLow:请求 64k/lq 时从低档位开始找(上游无该意识,移植补的最小档位选择,默认仍 sq 最高)。
*/
export const fetchAudioLink = async(
songID: string,
songType: string,
register?: (request: any) => void,
preferLow = false,
): Promise<AudioHit> => {
const url = `${MOBILE_API}/song/getSongUrl?songid=${encodeURIComponent(songID)}&songtype=${encodeURIComponent(songType)}`
const { statusCode, body } = await fetch5singJson(url, { register })
if (statusCode != 200 || !body || typeof body !== 'object') {
throw new Error(`5sing 取流失败(HTTP ${statusCode})`)
}
if (Number(body.code) !== 1000) {
// 实测 songtype 缺失/错误时 code=28 message=歌曲不存在
throw new Error(body.message ? `5sing 取流失败(${body.message})` : 'api returned error code')
}
const data = body.data ?? {}
const order = preferLow ? [...QUALITY_TIERS].reverse() : QUALITY_TIERS
for (const tier of order) {
const hit = tierUrl(data, tier)
if (hit && /^https?:\/\//i.test(hit)) return { type: tier, url: hit }
}
throw new Error('no valid download url found')
}
/** 上游 fetchSongInfo / GetLyrics 使用的 newget 详情(返回 data 对象) */
export const fetchNewGet = async(
songID: string,
songType: string,
register?: (request: any) => void,
): Promise<any> => {
const url = `${MOBILE_API}/song/newget?songid=${encodeURIComponent(songID)}&songtype=${encodeURIComponent(songType)}`
const { statusCode, body } = await fetch5singJson(url, { register })
if (statusCode != 200 || !body || typeof body !== 'object') {
throw new Error(`5sing 歌曲信息获取失败(HTTP ${statusCode})`)
}
return body.data ?? {}
}
/** 上游 newget 歌词:data.dynamicWords(LRC 文本) */
export const fetchDynamicLyric = async(
songID: string,
songType: string,
register?: (request: any) => void,
): Promise<string> => {
const data = await fetchNewGet(songID, songType, register)
return String(data?.dynamicWords ?? '')
}
/**
* 上游 fetchSongInfo:音频直链 + newget 元数据 → 统一 Song 形状。
* 取流失败即抛错(与上游 Parse 链路一致);newget 失败时保留兜底名 5sing_<type>_<id>。
*/
export const fetchSongInfo = async(songID: string, songType: string, register?: (request: any) => void) => {
const hit = await fetchAudioLink(songID, songType, register)
let name = ''
let artist = ''
let cover = ''
try {
const data = await fetchNewGet(songID, songType, register)
name = String(data?.SN ?? '')
artist = String(data?.user?.NN ?? '')
cover = String(data?.user?.I ?? '')
} catch {
// 上游 newget 失败时继续(metaBody 为 nil 不阻断)
}
if (!name) name = `5sing_${songType}_${songID}`
return {
songmid: `${songID}|${songType}`,
name,
singer: artist,
albumId: '',
albumName: '',
interval: 0,
img: cover || null,
lrc: null,
types: [],
_types: {},
url: hit.url,
type: hit.type,
link: buildSongLink(songType, songID),
songType,
typeName: kindName(songType),
source: '5sing' as const,
}
}
/** 时长估算的对外小工具(搜索结果用;上游 Search 的 Duration 公式) */
export const songInterval = (songSize: number): number => estimateDuration(songSize)
+56
View File
@@ -0,0 +1,56 @@
// 5sing 歌曲搜索(上游 fivesing/song.go 的 Search 移植)
// 接口: GET http://search.5sing.kugou.com/home/json?keyword=&sort=1&page=&filter=0&type=0
// 返回 { list: [...], type, pageInfo: { cur, totalCount, totalPages } },每页 10 条(服务端固定)。
// 内容种类:typeEname = yc(原创) / fc(翻唱) / bz(伴奏),中文名在 typeName;上游以 typeEname 入 ID/Extra,
// 本移植把它作为统一种类字段 songType 暴露(songmid 约定仍为 "<songId>|<typeEname>")。
// 上游 Search() 无 page 参数(硬编码 page=1);本移植按契约透传 page(实测 page 参数有效,pageInfo 可用于翻页)。
// 时长:上游用 songSize 估算(songSize*8/320000 秒);实测多数条目 songSize=0 → interval 返回 0。
import { formatPlayTime } from '../common/utils.ts'
import { SEARCH_API, fetch5singJson, removeEmTags, unescapeHtml, buildSongLink, estimateDuration, kindName } from './util.ts'
/** 上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5sing') */
export const mapSearchSong = (item: any) => {
const songType = String(item?.typeEname ?? '')
const duration = estimateDuration(Number(item?.songSize) || 0)
return {
songmid: `${item?.songId ?? ''}|${songType}`,
name: removeEmTags(unescapeHtml(item?.songName)),
singer: removeEmTags(unescapeHtml(item?.singer ?? item?.nickName)),
albumId: '',
albumName: '',
interval: duration > 0 ? formatPlayTime(duration) : 0,
img: null,
lrc: null,
types: [],
_types: {},
// 统一种类字段(上游 typeEname / typeName)
songType,
typeName: String(item?.typeName ?? kindName(songType)),
size: Number(item?.songSize) || 0,
link: buildSongLink(songType, item?.songId),
source: '5sing' as const,
}
}
export const search = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim()
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 20)
const empty = { list: [] as any[], allPage: 0, total: 0, limit: pageSize, source: '5sing' as const }
if (!word) return empty
const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=0`
const { statusCode, body } = await fetch5singJson(url)
if (statusCode != 200) throw new Error(`5sing 搜索失败(HTTP ${statusCode})`)
const rawList: any[] = Array.isArray(body?.list) ? body.list : []
const list = rawList.slice(0, pageSize).map(mapSearchSong)
const pageInfo = body?.pageInfo ?? {}
const total = Number(pageInfo.totalCount) || list.length
// 服务端每页 10 条(固定),allPage 直接用 pageInfo.totalPages
const allPage = Number(pageInfo.totalPages) || (list.length >= pageSize ? pageNo + 1 : pageNo)
return { list, allPage, total, limit: pageSize, source: '5sing' as const }
}
export default { search }
+18
View File
@@ -0,0 +1,18 @@
// 5sing 链接解析(上游 fivesing/song.go 的 Parse + playlist.go 的 ParsePlaylist 移植)
// 歌曲: 5sing.kugou.com/(yc|fc|bz)/<数字id>.html → fetchSongInfo(直链 + newget 元数据,与上游一致:无直链即报错)
// 歌单: /{userId}/dj/<id>.html → getPlaylistDetail 全量曲目(支持个性域名 userId,见 util.extractPlaylistLink)
// 专辑: 上游无专辑概念(不实现)
import { extractSongLink } from './util.ts'
import { fetchSongInfo } from './musicInfo.ts'
import { parsePlaylistLink } from './playlist.ts'
export const parseSong = async(link: string) => {
const parsed = extractSongLink(link)
if (!parsed) throw new Error('invalid 5sing link')
return fetchSongInfo(parsed.songID, parsed.songType)
}
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
export default { parseSong, parsePlaylist }
+251
View File
@@ -0,0 +1,251 @@
// 5sing 歌单(上游 fivesing/fivesing.go + playlist.go 移植)
// 搜索: GET http://search.5sing.kugou.com/home/json?...&type=1 → list[].songListId/title/pictureUrl/playCount/userName/songCnt/content/userId
// 上游对 userName 为空的条目再调 getsonglist?songfields=user 补创建者名(并发 10);本移植用同样回退、并发 5。
// 详情: ① GET mobileapi.5sing.kugou.com/song/getsonglist?id=&songfields=ID,user(元数据 T/C/P/H/E + user.ID/NN)
// ② 抓 http://5sing.kugou.com/{userId}/dj/{id}.html 解析 <li class="p_rel"> 块(上游 parseSongsFromHTML)
// ③ 数字 userId 页会 302 到个性域名页(如 /yeluoluo/dj/...),httpFetch 不跟跳 → util.fetch5sing 手动跟跳。
// 分类/个人歌单:上游显式返回不支持(model.ErrPlaylistCategoriesUnsupported / ErrUserPlaylistsUnsupported),本移植保留同样语义(抛错)。
// 链接解析: 上游正则只认数字 userId;本移植放宽支持个性域名(/name/dj/xxx.html)与省略 user 段,ID 以 API 反查为准。
import { MOBILE_API, SEARCH_API, fetch5sing, fetch5singJson, removeEmTags, unescapeHtml, buildSongLink, buildPlaylistLink, extractPlaylistLink, kindName } from './util.ts'
/** 上游 GetPlaylistCategories / GetCategoryPlaylists:显式不支持 */
export const getCategories = async() => {
throw new Error('playlist categories not supported')
}
export const getCategoryPlaylists = async(_categoryId?: any, _page = 1, _limit = 30) => {
throw new Error('playlist categories not supported')
}
/** 歌单列表项(HTML 解析出的歌曲)→ 统一 Song 形状 */
const mapPlaylistSong = (songID: string, kind: string, name: string, artist: string) => ({
songmid: `${songID}|${kind}`,
name,
singer: artist,
albumId: '',
albumName: '',
interval: 0,
img: null,
lrc: null,
types: [],
_types: {},
songType: kind,
typeName: kindName(kind),
link: buildSongLink(kind, songID),
source: '5sing' as const,
})
/**
* 上游 parseSongsFromHTML:从歌单页 HTML 提取歌曲。
* 块正则 `<li class="p_rel">([\s\S]*?)</li>`;块内 href 提取 yc/fc/bz 与 id,s_soner 提取歌手(上游的拼写错误 soner 保留)。
*/
export const parseSongsFromHTML = (htmlContent: string) => {
const blocks = [...String(htmlContent ?? '').matchAll(/<li class="p_rel">([\s\S]*?)<\/li>/g)]
if (!blocks.length) throw new Error('no songs found in playlist html (structure mismatch)')
// 上游正则要求 href="http://5sing.kugou.com/...";这里放宽协议(http/https/协议相对)
const songRe = /href="(?:(?:https?:)?\/\/)?5sing\.kugou\.com\/(yc|fc|bz)\/(\d+)\.html"[^>]*>([^<]+)<\/a>/
const artistRe = /class="s_soner[^"]*".*?>([^<]+)<\/a>/
const songs: any[] = []
const seen = new Set<string>()
for (const match of blocks) {
const blockHTML = match[1]
const songMatch = songRe.exec(blockHTML)
if (!songMatch) continue
const kind = songMatch[1]
const songID = songMatch[2]
const rawName = songMatch[3]
let artist = 'Unknown'
const artistMatch = artistRe.exec(blockHTML)
if (artistMatch) artist = artistMatch[1]
const uniqueKey = `${kind}|${songID}`
if (seen.has(uniqueKey)) continue
seen.add(uniqueKey)
const name = removeEmTags(unescapeHtml(rawName)).trim()
artist = removeEmTags(unescapeHtml(artist)).trim()
songs.push(mapPlaylistSong(songID, kind, name, artist))
}
return songs
}
/** 上游 fetchCreatorName:userName 为空时用 getsonglist?songfields=user 反查创建者名(data 必须是对象) */
export const fetchCreatorName = async(playlistId: string): Promise<string> => {
if (!playlistId) return ''
try {
const { statusCode, body } = await fetch5singJson(`${MOBILE_API}/song/getsonglist?id=${encodeURIComponent(playlistId)}&songfields=user`)
if (statusCode != 200 || !body || typeof body !== 'object') return ''
const data = body.data
if (!data || typeof data !== 'object' || Array.isArray(data)) return ''
return String(data?.user?.NN ?? '')
} catch {
return ''
}
}
/** 歌单搜索项 → 摘要(与既有平台 playlist 搜索项对齐) */
const mapPlaylistSummary = (item: any) => {
const id = String(item?.songListId ?? '')
const userId = String(item?.userId ?? '')
const desc = removeEmTags(unescapeHtml(item?.content))
const creator = String(item?.userName ?? '') || (userId ? `ID: ${userId}` : '')
return {
id,
name: removeEmTags(unescapeHtml(item?.title)),
cover: String(item?.pictureUrl ?? ''),
trackCount: Number(item?.songCnt) || 0,
playCount: Number(item?.playCount) || 0,
creator,
creatorId: userId,
// 上游怪癖:content 为字符串 "0" 时视为空简介
description: desc === '0' ? '' : desc,
link: buildPlaylistLink(userId, id),
source: '5sing' as const,
}
}
/** 上游 SearchPlaylist(type=1),补创建者名(并发 5) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim()
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 20)
const empty = { list: [] as any[], total: 0, page: pageNo, limit: pageSize, source: '5sing' as const }
if (!word) return empty
const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=1`
const { statusCode, body } = await fetch5singJson(url)
if (statusCode != 200) throw new Error(`5sing 歌单搜索失败(HTTP ${statusCode})`)
const rawList: any[] = Array.isArray(body?.list) ? body.list : []
const list = rawList.slice(0, pageSize).map(mapPlaylistSummary)
// 上游并行回退:item.userName 为空时反查真实创建者名(失败保留 "ID: xxx")
const targets = list.filter((pl) => pl.creatorId && pl.creator === `ID: ${pl.creatorId}`)
for (let i = 0; i < targets.length; i += 5) {
await Promise.all(targets.slice(i, i + 5).map(async(pl) => {
const name = await fetchCreatorName(pl.id)
if (name) pl.creator = name
}))
}
const total = Number(body?.pageInfo?.totalCount) || list.length
return { list, total, page: pageNo, limit: pageSize, source: '5sing' as const }
}
/** 元数据 + 全量曲目(上游 fetchPlaylistDetail 核心;HTML 不分页,一次拿全) */
export const fetchPlaylistAll = async(playlistIdRaw: string) => {
const playlistId = String(playlistIdRaw ?? '').trim()
if (!playlistId) throw new Error('invalid 5sing playlist id')
// 1) API 元数据(标题/封面/统计/UserID)
const { statusCode, body } = await fetch5singJson(
`${MOBILE_API}/song/getsonglist?id=${encodeURIComponent(playlistId)}&songfields=ID,user`,
)
if (statusCode != 200) throw new Error(`fetch info failed: HTTP ${statusCode}`)
// 上游用 RawMessage 判多态:歌单不存在/无权限时 data 为 [](数组),直接报错
const data = body?.data
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('playlist info not found or invalid (api returned empty list)')
}
const userId = String(data?.user?.ID ?? '')
if (!userId) throw new Error('playlist user not found or invalid id')
const playlist = {
id: playlistId,
name: String(data?.T ?? ''),
cover: String(data?.P ?? ''),
trackCount: Number(data?.E) || 0,
playCount: Number(data?.H) || 0,
creator: String(data?.user?.NN ?? ''),
description: String(data?.C ?? ''),
link: buildPlaylistLink(userId, playlistId),
userId,
source: '5sing' as const,
}
// 2) 歌单页 HTML(数字 userId 会 302 → 个性域名页,fetch5sing 已跟跳)
const page = await fetch5sing(playlist.link)
if (page.statusCode != 200) throw new Error(`fetch html failed: HTTP ${page.statusCode}`)
// 3) 解析曲目(上游:解析失败不影响元数据返回,返回空列表)
let songs: any[] = []
try {
songs = parseSongsFromHTML(String(page.body ?? ''))
} catch {
songs = []
}
return { playlist, songs }
}
/** 歌单详情(分页切片;total 为解析到的有效曲目数,trackCount 保留上游元数据 E) */
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const { playlist, songs } = await fetchPlaylistAll(id)
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 50)
const start = (pageNo - 1) * pageSize
const list = songs.slice(start, start + pageSize)
const total = songs.length
return {
source: '5sing' as const,
info: {
id: playlist.id,
name: playlist.name,
img: playlist.cover,
desc: playlist.description,
author: playlist.creator,
link: playlist.link,
trackCount: playlist.trackCount,
playCount: playlist.playCount,
},
list,
page: pageNo,
limit: pageSize,
total,
allPage: Math.ceil(total / pageSize) || 1,
}
}
/** 上游 GetPlaylistSongs:仅返回曲目列表(全量) */
export const getPlaylistSongs = async(id: string) => {
const { songs } = await fetchPlaylistAll(id)
return songs
}
/** 上游 ParsePlaylist:歌单链接 → 详情(全量曲目,匹配上游返回全部歌曲的行为) */
export const parsePlaylistLink = async(link: string) => {
const parsed = extractPlaylistLink(link)
if (!parsed) throw new Error('invalid 5sing playlist link')
const { playlist, songs } = await fetchPlaylistAll(parsed.playlistId)
return {
source: '5sing' as const,
info: {
id: playlist.id,
name: playlist.name,
img: playlist.cover,
desc: playlist.description,
author: playlist.creator,
link: playlist.link,
trackCount: playlist.trackCount,
playCount: playlist.playCount,
},
list: songs,
page: 1,
limit: songs.length || 1,
total: songs.length,
allPage: 1,
}
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
getCategories,
getCategoryPlaylists,
parsePlaylistLink,
}
+188
View File
@@ -0,0 +1,188 @@
// 5sing 公共工具(上游 fivesing/fivesing.go 的 getFirstValid / removeEmTags 等移植)
// 平台 id 统一为 '5sing'(上游包名 fivesing,Go 源码 model.Source 用 "fivesing";
// 本仓库契约要求 id 为 5sing,故所有对外 source 字段均为 '5sing'。
// 上游 New(cookie) 的 cookie 实际参与请求(搜索/getSongUrl/newget/歌单页),
// 这里按约定从 sdk/auth/store.ts 的 getCredentials('5sing') 读取(无则匿名)。
import { httpFetch } from '../request.ts'
import { getCredentials } from '../auth/store.ts'
export const UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
/** 上游 fivesing.go 的常量:手机端 API 主机(http 明文,实测无 302) */
export const MOBILE_API = 'http://mobileapi.5sing.kugou.com'
/** 搜索接口(type=0 单曲 / type=1 歌单) */
export const SEARCH_API = 'http://search.5sing.kugou.com/home/json'
/** 作品类型(上游 typeEname;yc=原创 fc=翻唱 bz=伴奏) */
export const SONG_KINDS: Record<string, string> = {
yc: '原创',
fc: '翻唱',
bz: '伴奏',
}
/** 上游 getFirstValid:返回第一个非空字符串(空串视为无效) */
export const getFirstValid = (...urls: Array<string | null | undefined>): string => {
for (const u of urls) {
if (u) return String(u)
}
return ''
}
/** 上游 removeEmTags:去掉搜索结果里 <em class="keyword"> 高亮标签 */
export const removeEmTags = (s: any): string =>
String(s ?? '')
.replaceAll('<em class="keyword">', '')
.replaceAll('</em>', '')
.trim()
const HTML_ENTITIES: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
copy: '©',
reg: '®',
hellip: '…',
mdash: '—',
ndash: '–',
}
/** Go html.UnescapeString 的等价实现(常用实体 + 数字实体) */
export const unescapeHtml = (s: any): string =>
String(s ?? '').replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity: string) => {
if (entity.startsWith('#x') || entity.startsWith('#X')) {
const code = Number.parseInt(entity.slice(2), 16)
return Number.isFinite(code) ? String.fromCodePoint(code) : match
}
if (entity.startsWith('#')) {
const code = Number.parseInt(entity.slice(1), 10)
return Number.isFinite(code) ? String.fromCodePoint(code) : match
}
return HTML_ENTITIES[entity.toLowerCase()] ?? match
})
/** 上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用) */
export const get5singCookie = (): string => String((getCredentials('5sing') as { cookie?: string } | null)?.cookie ?? '')
/** 登录态(凭据存在性判断,与 me 平台 status 对齐) */
export const getStatus = () => {
const cred = getCredentials('5sing') as { cookie?: string, updatedAt?: string } | null
return cred?.cookie ? { logged: true, updatedAt: cred.updatedAt } : { logged: false }
}
/** 是否显式声明了非本平台的 source(上游 GetLyrics/GetDownloadURL 的 source mismatch 检查) */
export const isForeignSource = (songInfo: any): boolean =>
Boolean(songInfo?.source) && songInfo.source !== '5sing'
/**
* 带 Cookie/UA 的请求(上游 utils.Get + http.Client 自动跟跳的等价实现)
* httpFetch 不跟随 302:这里手动跟跳最多 3 次(5sing 老链接 http→https / 数字 userId→个性域名)。
*/
export const fetch5sing = async(
url: string,
options: { headers?: Record<string, any>, timeout?: number, register?: (request: any) => void } = {},
): Promise<any> => {
const cookie = get5singCookie()
const headers = {
'User-Agent': UserAgent,
...(cookie ? { Cookie: cookie } : {}),
...(options.headers || {}),
}
let current = String(url)
let response: any = null
for (let hop = 0; hop <= 3; hop++) {
const request = httpFetch(current, { headers, timeout: options.timeout ?? 15000 })
options.register?.(request)
response = await request.promise
const status = Number(response?.statusCode) || 0
const location = response?.headers?.location
if (status >= 300 && status < 400 && location) {
try {
current = new URL(String(location), current).href
} catch {
return response
}
continue
}
return response
}
return response
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
/**
* 5sing 手机端 API 有频率控制(实测连续请求会返回 { code: 13, message: "操作太频繁", data: [] }),
* 上游 Go 无重试;本移植对 JSON 接口做最多 2 次退避重试,保证搜索/取流/歌词/歌单在突发请求下稳定。
*/
export const fetch5singJson = async(
url: string,
options: { headers?: Record<string, any>, timeout?: number, register?: (request: any) => void } = {},
): Promise<any> => {
let response: any = null
for (let attempt = 0; attempt < 3; attempt++) {
response = await fetch5sing(url, options)
const code = Number(response?.body?.code)
const message = String(response?.body?.message ?? '')
if ((code === 13 || message.includes('太频繁')) && attempt < 2) {
await sleep(800 * (attempt + 1))
continue
}
return response
}
return response
}
/** 歌曲页链接(上游 Link 拼法:http://5sing.kugou.com/{typeEname}/{songId}.html) */
export const buildSongLink = (songType: string, songId: string | number): string =>
songType && songId ? `http://5sing.kugou.com/${songType}/${songId}.html` : ''
/** 歌单页链接(上游 Link 拼法:http://5sing.kugou.com/{userId}/dj/{playlistId}.html) */
export const buildPlaylistLink = (userId: string | number, playlistId: string): string =>
userId && playlistId ? `http://5sing.kugou.com/${userId}/dj/${playlistId}.html` : ''
/**
* 解析歌曲的 <songid>|<songtype> 约定(上游 model.Song.ID: "123|yc" + Extra{songid,songtype})
* 兼容只给了 songType 字段(路由层只透传 songmid 时用 `|` 分隔)。
*/
export const extractSongId = (
songmid: any,
songTypeFallback?: any,
): { songID: string, songType: string } => {
const raw = String(songmid ?? '')
const [idPart = '', typePart = ''] = raw.split('|')
return {
songID: idPart || '',
songType: String(typePart || songTypeFallback || ''),
}
}
/** 上游 Parse 的正则:5sing.kugou.com/(\w+)/(\d+).html(专辑/歌手页不匹配) */
export const extractSongLink = (link: any): { songType: string, songID: string } | null => {
const match = /5sing\.kugou\.com\/(\w+)\/(\d+)\.html/.exec(String(link ?? ''))
if (!match) return null
return { songType: match[1], songID: match[2] }
}
/**
* 上游 ParsePlaylist 的正则增强:
* 原正则 `5sing\.kugou\.com/(?:(\d+)/)?dj/([a-zA-Z0-9]+)\.html` 只认数字 userId,
* 个性域名(如 /yeluoluo/dj/xxx.html)与省略 user 段的链接会漏;userId 本身不参与请求(API 反查更准),故放宽为任意单段。
*/
export const extractPlaylistLink = (link: any): { userId: string, playlistId: string } | null => {
const match = /5sing\.kugou\.com\/(?:([^/?#]+)\/)?dj\/([a-zA-Z0-9]+)\.html/.exec(String(link ?? ''))
if (!match) return null
return { userId: match[1] || '', playlistId: match[2] }
}
/** 上游 Search 的时长估算:songSize*8/320000(320kbps 假设;songSize 为 0 时返回 0) */
export const estimateDuration = (songSize: number): number => {
const size = Number(songSize) || 0
return size > 0 ? Math.trunc((size * 8) / 320000) : 0
}
/** 作品类型中文名(typeName 缺失时兜底) */
export const kindName = (songType: string): string => SONG_KINDS[songType] ?? ''