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,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,
|
||||
}
|
||||
Reference in new issue
Block a user