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:
1 parent
47ce651f46
commit
33b96f92c4
54 files changed
+6161
-5
No files matched your search
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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] ?? ''
|
||||
Reference in new issue
Block a user