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

+39
View File
@@ -0,0 +1,39 @@
// Bilibili(B站)平台模块 —— 上游 music-lib/bilibili 的移植(平台 id: bili)
// 能力:搜索 / 取流(DASH,VIP 可出 FLAC/Hi-Res)/ 歌单(合集 ugc_season + 多分P稿件)/
// 链接解析(BV/av、分P)/ 封面 / 扫码登录(sdk/bili/login.ts,凭据落 sdk/auth/store.ts)
// 不实现:专辑、歌词(上游即空实现)、榜单、热搜、歌手、评论、个人歌单、歌单分类(上游均未实现)。
// 取流入口在 sdk/apis/bili.ts(getMusicUrl),songmid 约定 `<bvid>|<cid>`。
import musicSearch from './musicSearch.ts'
import lyric from './lyric.ts'
import pic from './pic.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import biliApi from '../apis/bili.ts'
import { parseSongId } from './util.ts'
const bili = {
musicSearch,
getMusicUrl(songInfo, type) {
return biliApi.getMusicUrl(songInfo, type)
},
getLyric(songInfo, ...rest) {
return lyric.getLyric(songInfo, ...rest)
},
getPic(songInfo) {
return pic.getPic(songInfo)
},
playlist,
parse,
getMusicDetailPageUrl(songInfo) {
if (songInfo?.link) return String(songInfo.link)
const bvid = String(songInfo?.bvid || '') || parseSongId(songInfo?.songmid).bvid
if (!bvid) return 'https://www.bilibili.com/'
const page = Number(songInfo?.page) || 0
return `https://www.bilibili.com/video/${bvid}${page > 1 ? `?p=${page}` : ''}`
},
}
// 登录态(sdk/auth/store.ts 的 bili 凭据,扫码登录写入)
export { status } from './login.ts'
export default bili
+163
View File
@@ -0,0 +1,163 @@
// Bilibili 扫码登录(上游 bilibili/login.go 的 CreateQRLogin/CheckQRLogin + account.go 的 IsVipAccount)
// 接口:
// GET https://passport.bilibili.com/x/passport-login/web/qrcode/generate → data.{url, qrcode_key}
// GET https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key= → data.code
// 0 成功 / 86038 过期 / 86090 已扫码待确认 / 86101 未扫码
// 成功时登录态 Cookie 在 poll 响应的 Set-Cookie 与 data.url 的 crossDomain 查询串里(两处都收集)。
// 凭据落盘到 sdk/auth/store.ts 的 `bili` 记录(cookie / refreshToken / 账号资料),与 kg/wy 一致;
// 上游 New(cookie) 的实例化语义由 getCredentials('bili') 承担(见 util.ts getCookie)。
// 状态码沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功。
import QRCode from 'qrcode'
import { httpFetch } from '../common/request.ts'
import { getCredentials, setCredentials } from '../auth/store.ts'
import { REFERER, UA } from './util.ts'
import { resetVipCache } from '../apis/bili.ts'
const QR_GENERATE_API = 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate'
const QR_POLL_API = 'https://passport.bilibili.com/x/passport-login/web/qrcode/poll'
/** 上游 utils.WithRandomIPHeader:随机国内 IP,规避部分区域限制 */
const randomChinaIp = (): string => {
const first = [36, 42, 58, 60, 101, 106, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 171, 175, 180, 182, 183, 210, 211, 218, 219, 220, 221, 222, 223][Math.floor(Math.random() * 35)]
return `${first}.${1 + Math.floor(Math.random() * 254)}.${1 + Math.floor(Math.random() * 254)}.${1 + Math.floor(Math.random() * 254)}`
}
const qrHeaders = (): Record<string, string> => {
const ip = randomChinaIp()
return {
'User-Agent': UA,
Referer: REFERER,
'X-Forwarded-For': ip,
'X-Real-IP': ip,
}
}
const cookieMapFromHeaders = (headers: any): Record<string, string> => {
const raw = headers?.['set-cookie']
const values: string[] = Array.isArray(raw) ? raw : raw ? [String(raw)] : []
const jar: Record<string, string> = {}
for (const value of values) {
const pair = String(value).split(';')[0].trim()
const idx = pair.indexOf('=')
if (idx > 0) jar[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim()
}
return jar
}
/** crossDomain 回跳地址的查询串里也带 SESSDATA 等(无 cookie 包时后备) */
const cookieMapFromCrossDomain = (crossUrl: string): Record<string, string> => {
const jar: Record<string, string> = {}
try {
const url = new URL(String(crossUrl || ''))
for (const name of ['DedeUserID', 'DedeUserID__ckMd5', 'SESSDATA', 'bili_jct', 'sid']) {
const value = url.searchParams.get(name)
if (value) jar[name] = value
}
} catch {
// 非 URL 时忽略
}
return jar
}
const cookieString = (jar: Record<string, string>): string =>
Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ')
/** 创建扫码登录会话:返回 { key, qrurl, qrimg, expiresAt }(qrimg 可直接展示) */
export const createQRLogin = async() => {
const { statusCode, body } = await httpFetch(QR_GENERATE_API, { headers: qrHeaders(), timeout: 15000 }).promise as any
if (statusCode !== 200 || !body || typeof body !== 'object') {
throw new Error(`获取B站登录二维码失败(HTTP ${statusCode})`)
}
if (body.code !== 0 || !String(body.data?.qrcode_key || '').trim()) {
throw new Error(`获取B站登录二维码失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
}
const key = String(body.data.qrcode_key)
const url = String(body.data.url || '')
const qrimg = await QRCode.toDataURL(url, { errorCorrectionLevel: 'M', margin: 2, width: 360 }).catch(() => '')
return {
key,
qrurl: url,
qrimg,
// 上游 QRLoginSession.ExpiresAt:3 分钟有效期
expiresAt: Math.floor(Date.now() / 1000) + 180,
}
}
/** 拉取账号资料(nav;失败不阻断登录) */
const fetchProfile = async(cookie: string) => {
try {
const { body } = await httpFetch('https://api.bilibili.com/x/web-interface/nav', {
headers: { 'User-Agent': UA, Referer: REFERER, Cookie: cookie },
timeout: 15000,
}).promise as any
const data = body?.data ?? {}
return {
mid: data.mid ?? null,
nickname: data.uname ?? null,
vipType: Number(data.vipType) || 0,
vipStatus: Number(data.vipStatus) || 0,
}
} catch {
return { mid: null, nickname: null, vipType: 0, vipStatus: 0 }
}
}
/**
* 轮询扫码状态
* @returns {{ code: 800|801|802|803, message: string, status: string, profile? }}
* 800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(凭据已落盘)
*/
export const checkQRLogin = async(key: string) => {
const qrcodeKey = String(key || '').trim()
if (!qrcodeKey) throw new Error('B站扫码 key 为空')
const { statusCode, headers, body } = await httpFetch(`${QR_POLL_API}?qrcode_key=${encodeURIComponent(qrcodeKey)}`, {
headers: { 'User-Agent': UA, Referer: REFERER },
timeout: 15000,
}).promise as any
if (statusCode !== 200) {
throw Object.assign(new Error(`轮询B站扫码状态失败(HTTP ${statusCode})`), { status: 502 })
}
const data = body?.data ?? {}
const code = Number(data.code)
if (code === 0) {
const jar = { ...cookieMapFromCrossDomain(data.url), ...cookieMapFromHeaders(headers) }
const cookie = cookieString(jar)
if (!cookie) throw new Error('B站登录成功但未获取到 Cookie,请重试')
const profile = await fetchProfile(cookie)
setCredentials('bili', {
cookie,
refreshToken: String(data.refresh_token || ''),
mid: profile.mid,
nickname: profile.nickname,
vipType: profile.vipType,
vipStatus: profile.vipStatus,
})
resetVipCache()
return { code: 803, message: '登录成功', status: 'success', profile }
}
const statusMap: Record<number, [number, string, string]> = {
86038: [800, '二维码已过期', 'expired'],
86090: [802, '已扫码,待确认', 'scanned'],
86101: [801, '等待扫码', 'waiting'],
}
const [mappedCode, message, status] = statusMap[code] ?? [800, String(data.message || `未知状态 ${code}`), 'failed']
return { code: mappedCode, message, status }
}
/** 当前登录态(凭据存在性 + 落盘时保存的账号资料) */
export const status = () => {
const cred = getCredentials('bili') as any
return cred?.cookie
? {
logged: true,
nickname: cred.nickname ?? null,
mid: cred.mid ?? null,
vipType: cred.vipType ?? 0,
updatedAt: cred.updatedAt,
}
: { logged: false }
}
export default { createQRLogin, checkQRLogin, status }
+12
View File
@@ -0,0 +1,12 @@
// Bilibili 歌词(上游 bilibili/lyric.go 即空实现:GetLyrics 对 bilibili 源恒返回空字符串)
// B 站视频没有公开歌词接口(cc_subtitle 需登录且绝大多数视频没有),上游未实现,此处保持一致:
// 返回统一结构 { lyric: '', tlyric: '' },让 /lyric/bili 有响应而不是 404。
export const getLyric = (_songInfo?: any, ..._rest: any[]) => {
const requestObj = {
promise: Promise.resolve({ lyric: '', tlyric: '' }),
cancelHttp() {},
}
return requestObj
}
export default { getLyric }
+57
View File
@@ -0,0 +1,57 @@
// Bilibili 搜索(上游 bilibili/song.go 的 `Search`)
// 接口: https://api.bilibili.com/x/web-interface/search/type?search_type=video&keyword=&page=&page_size=
// 上游对每个搜索结果再调 x/web-interface/view 取首个分P的 cid/时长(B 站取流必须 cid),本移植保持一致,
// 只是把逐个串行改成了 4 并发;单条 view 失败按上游语义跳过该条。
// 实测(2026-10-02):带 Referer/UA + 匿名(或登录)Cookie 可返回 code 0;风控时返回 -412/-352,会抛错由路由兜底。
import { apiGet, cleanTitle, fetchView, makeSong, mapLimit } from './util.ts'
const SEARCH_URL = 'https://api.bilibili.com/x/web-interface/search/type'
export const search = async(keyword: string, page = 1, limit = 20) => {
const p = Math.max(1, Number(page) || 1)
const l = Math.min(Math.max(1, Number(limit) || 20), 50)
const url = `${SEARCH_URL}?search_type=video&keyword=${encodeURIComponent(keyword)}&page=${p}&page_size=${l}`
const { statusCode, body } = await apiGet(url)
if (statusCode !== 200 || !body || typeof body !== 'object') {
throw new Error(`B站搜索失败(HTTP ${statusCode})`)
}
if (body.code !== 0) {
throw new Error(`B站搜索失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
}
const items: any[] = body.data?.result ?? []
const songs = await mapLimit(items, 4, async(item: any) => {
if (!item?.bvid) return null
const view = await fetchView(item.bvid)
const pages: any[] = view?.pages ?? []
if (!pages.length) return null
const first = pages[0]
const rootTitle = cleanTitle(item.title)
let displayTitle = first.part || ''
if (!displayTitle) displayTitle = rootTitle
else if (displayTitle !== rootTitle) displayTitle = `${rootTitle} - ${displayTitle}`
return makeSong({
bvid: item.bvid,
cid: first.cid,
name: displayTitle,
artist: item.author || view?.owner?.name || '',
album: item.bvid,
duration: first.duration,
cover: item.pic || view?.pic || '',
page: 1,
})
})
const list = songs.filter(Boolean)
const total = Number(body.data?.numResults) || list.length
const numPages = Number(body.data?.numPages) || 0
return {
list,
allPage: numPages || p + (list.length >= l ? 1 : 0),
total,
limit: l,
source: 'bili' as const,
}
}
export default { search }
+72
View File
@@ -0,0 +1,72 @@
// Bilibili 链接解析(上游 bilibili/song.go 的 `Parse`)
// 支持 https://www.bilibili.com/video/BVxxx?p=2 与 av 号(上游只认 BV,任务要求兼容 av);
// 与上游一致:合集(ugc_season)或多分P链接会被判定为歌单并抛错,请改用 playlist.parsePlaylistLink。
// 上游 Parse 会顺带填入下载地址,这里同样 best-effort 填充 url(失败不影响元数据返回)。
import { extractAid, extractBvid, extractPage, fetchPageList, fetchView, makeSong, normalizeCover } from './util.ts'
import { getAudioUrl } from '../apis/bili.ts'
import { parsePlaylistLink } from './playlist.ts'
export const parseSong = async(link: string) => {
const raw = String(link || '').trim()
const bvid = extractBvid(raw)
const aid = extractAid(raw)
if (!bvid && !aid) throw new Error('无效的B站链接:未找到 BV/av 号')
const view = await fetchView(bvid || aid)
const pages: any[] = view?.pages ?? []
// 上游:合集或多分P视为歌单链接(Parse 只处理单P)
if (view?.ugc_season || pages.length > 1) {
throw new Error('该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)')
}
if (pages.length <= 1) {
try {
const pageList = await fetchPageList(view.bvid || bvid)
if (pageList.length > 1) throw new Error('该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)')
} catch (err: any) {
if (String(err?.message || '').includes('歌单解析')) throw err
// 分P列表接口失败时按上游语义忽略
}
}
if (!pages.length) throw new Error('未找到视频分P')
let page = extractPage(raw)
if (page > pages.length) page = 1
const target = pages[page - 1]
const rootTitle = view.title || ''
let displayTitle = target?.part || ''
if (pages.length === 1 && !displayTitle) displayTitle = rootTitle
else if (displayTitle !== rootTitle) displayTitle = `${rootTitle} - ${displayTitle}`
const cid = String(target?.cid ?? '')
const song: any = makeSong({
bvid: view.bvid || bvid,
cid,
name: displayTitle,
artist: view?.owner?.name || '',
album: view.bvid || bvid,
duration: target?.duration,
cover: normalizeCover(view?.pic || ''),
page,
})
// 上游 Parse 会立即填充音频地址(失败忽略)
if (cid) {
try {
const audio = await getAudioUrl(song.bvid, cid)
if (audio?.url) {
song.url = audio.url
song.type = audio.type
}
} catch {
// 忽略:元数据仍可返回
}
}
return song
}
/** 歌单/合集链接解析(上游 bilibili/playlist.go 的 `ParsePlaylist`) */
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
export default { parseSong, parsePlaylist }
+17
View File
@@ -0,0 +1,17 @@
// Bilibili 封面:搜索结果/合集条目已带封面;仅有 id 时回源 x/web-interface/view 取 pic
import { fetchView, normalizeCover, parseSongId } from './util.ts'
export const getPic = async(songInfo: any): Promise<string> => {
const direct = String(songInfo?.img || '')
if (/^https?:/i.test(direct)) return direct
const bvid = String(songInfo?.bvid || '') || parseSongId(songInfo?.songmid).bvid
if (!bvid) return ''
try {
const view = await fetchView(bvid)
return normalizeCover(view?.pic || '')
} catch {
return ''
}
}
export default { getPic }
+257
View File
@@ -0,0 +1,257 @@
// Bilibili 歌单(上游 bilibili/playlist.go;B站的"歌单"= 合集 ugc_season / 多分P稿件)
// SearchPlaylist: 用视频搜索 + view 判断,把「合集」和「多分P稿件」当歌单返回;
// GetPlaylistSongs / ParsePlaylist: id 约定 `season:<season_id>:<mid>:<bvid>` 或 `bvid:<bvid>`;
// GetPlaylistCategories / GetCategoryPlaylists: 上游显式返回 ErrPlaylistCategoriesUnsupported,本移植保持不支持(抛错)。
// 注意:上游用于补全合集信息的 x/space/ugc/season 接口现已 404,bvid 路径(view 内自带 sections)是主通道。
import { apiGet, buildSongsFromPages, buildSongsFromSeasonSections, cleanTitle, extractAid, extractBvid, fetchPageList, fetchSeasonArchiveIndex, fetchSeasonSongs, fetchView, mapLimit, normalizeCover } from './util.ts'
const SEARCH_URL = 'https://api.bilibili.com/x/web-interface/search/type'
const countSeasonEpisodes = (sections: any[]): number => {
let count = 0
for (const sec of sections || []) count += (sec?.episodes || []).length
return count
}
const seasonKey = (seasonId: any, mid: any, bvid: string) => `season:${seasonId}:${mid}:${bvid}`
const bvidKey = (bvid: string) => `bvid:${bvid}`
/** 搜索合集/多分P(上游 SearchPlaylist;page/limit 为移植新增的翻页能力,语义同其它平台) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const p = Math.max(1, Number(page) || 1)
const l = Math.min(Math.max(1, Number(limit) || 20), 50)
const url = `${SEARCH_URL}?search_type=video&keyword=${encodeURIComponent(keyword)}&page=${p}&page_size=${l}`
const { statusCode, body } = await apiGet(url)
if (statusCode !== 200 || !body || typeof body !== 'object') {
throw new Error(`B站歌单搜索失败(HTTP ${statusCode})`)
}
if (body.code !== 0) {
throw new Error(`B站歌单搜索失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
}
const items: any[] = body.data?.result ?? []
const seen = new Set<string>()
const result = await mapLimit(items, 4, async(item: any) => {
if (!item?.bvid) return null
const view = await fetchView(item.bvid)
const cover = normalizeCover(item.pic || view?.pic || '')
const season = view?.ugc_season
if (season) {
const id = seasonKey(season.id, view?.owner?.mid, item.bvid)
if (seen.has(id)) return null
seen.add(id)
let trackCount = countSeasonEpisodes(season.sections)
if (!trackCount && season.id && view?.owner?.mid) {
try {
trackCount = (await fetchSeasonSongs(view.owner.mid, season.id)).length
} catch {
// 上游 fetchSeasonSongs 失败同样保持 trackCount 0
}
}
return {
id,
name: season.title || cleanTitle(item.title),
cover: normalizeCover(season.cover) || cover,
trackCount,
playCount: Number(season?.stat?.view) || undefined,
creator: view?.owner?.name || item.author || '',
description: season.intro || '',
link: `https://www.bilibili.com/video/${item.bvid}`,
source: 'bili' as const,
}
}
const pages: any[] = view?.pages ?? []
if (pages.length > 1) {
const id = bvidKey(item.bvid)
if (seen.has(id)) return null
seen.add(id)
return {
id,
name: cleanTitle(item.title),
cover,
trackCount: pages.length,
creator: view?.owner?.name || item.author || '',
description: '',
link: `https://www.bilibili.com/video/${item.bvid}`,
source: 'bili' as const,
}
}
return null
})
const list = result.filter(Boolean)
return { list, page: p, limit: l, total: list.length, source: 'bili' as const }
}
/** 由 bvid 构造歌单详情(上游 GetPlaylistSongs 的 bvid 分支 + ParsePlaylist 的元数据) */
const detailFromBvid = async(bvid: string) => {
const view = await fetchView(bvid)
const realBvid = view?.bvid || bvid
let pages: any[] = view?.pages ?? []
if (pages.length <= 1) {
const pageList = await fetchPageList(realBvid).catch(() => null)
if (pageList?.length) pages = pageList
}
if (!pages.length) throw new Error('未找到视频分P')
const songs = buildSongsFromPages(realBvid, view?.title || '', view?.owner?.name || '', view?.pic || '', pages)
return {
info: {
name: view?.title || realBvid,
img: normalizeCover(view?.pic || ''),
desc: String(view?.desc || ''),
author: view?.owner?.name || '',
},
songs,
}
}
/** 由 `season:<season_id>:<mid>:<bvid>` 构造歌单详情(上游 GetPlaylistSongs 的 season 分支) */
const detailFromSeason = async(seasonId: string, mid: string, bvid: string) => {
let info = { name: '', img: '', desc: '', author: '' }
let songs: any[] = []
if (bvid) {
const view = await fetchView(bvid)
const season = view?.ugc_season
if (season) {
let archiveIndex: Record<string, { title: string, cover: string, duration: number }> = {}
let seasonTitle = season.title || ''
let seasonCover = season.cover || ''
// 上游会先拉 archive 索引补全缺失字段;该接口现已 404,失败时按空索引继续
const idx = await fetchSeasonArchiveIndex(mid, seasonId).catch(() => null)
if (idx) {
archiveIndex = idx.index
seasonTitle = seasonTitle || idx.title
seasonCover = seasonCover || idx.cover
}
songs = buildSongsFromSeasonSections(season.sections || [], seasonTitle, seasonCover, view?.owner?.name || '', archiveIndex)
info = {
name: seasonTitle || view?.title || '',
img: normalizeCover(seasonCover || view?.pic || ''),
desc: season.intro || '',
author: view?.owner?.name || '',
}
}
}
if (!songs.length) {
songs = await fetchSeasonSongs(mid, seasonId)
if (!info.name) {
const idx = await fetchSeasonArchiveIndex(mid, seasonId).catch(() => null)
if (idx) info = { ...info, name: idx.title || '', img: normalizeCover(idx.cover || ''), desc: '', author: '' }
}
}
if (!songs.length) throw new Error('合集为空或合集接口不可用')
return { info, songs }
}
/** 解析 id(兼容链接/裸 BV/av/bvid: 前缀) */
const resolveId = async(id: string): Promise<{ kind: 'link' | 'season' | 'bvid', value: string, parts?: string[] }> => {
const raw = String(id || '').trim()
if (/^https?:/i.test(raw)) return { kind: 'link', value: raw }
if (/^season:/i.test(raw)) return { kind: 'season', value: raw, parts: raw.split(':') }
// 注意先去 `bvid:` 前缀再提 BV:extractBvid 的大小写不敏感匹配会把前缀 "bvid" 本身当成 BV 号
const bvid = extractBvid(/^bvid:/i.test(raw) ? raw.replace(/^bvid:/i, '') : raw)
if (bvid) return { kind: 'bvid', value: bvid }
const aid = extractAid(raw)
if (aid) {
const view = await fetchView(aid)
const realBvid = view?.bvid || ''
if (!realBvid) throw new Error('未找到视频 BV 号')
return { kind: 'bvid', value: realBvid }
}
throw new Error('无效的歌单 id(支持 season:<season_id>:<mid>:<bvid>、bvid:<bvid> 或 BV 号/链接)')
}
const pageSlice = (detail: { info: any, songs: any[] }, page: any, limit: any) => {
const p = Math.max(1, Number(page) || 1)
const l = Math.min(Math.max(1, Number(limit) || 100), 10000)
return {
source: 'bili' as const,
info: detail.info,
list: detail.songs.slice((p - 1) * l, p * l),
page: p,
limit: l,
total: detail.songs.length,
}
}
/** 歌单详情(契约键 playlist.getPlaylistDetail) */
export const getPlaylistDetail = async(id: string, page = 1, limit = 100) => {
const target = await resolveId(id)
if (target.kind === 'link') return parsePlaylistLink(target.value)
if (target.kind === 'season') {
const parts = target.parts || []
if (parts.length < 3) throw new Error('无效的合集 id')
return pageSlice(await detailFromSeason(parts[1], parts[2], parts[3] || ''), page, limit)
}
return pageSlice(await detailFromBvid(target.value), page, limit)
}
/** 上游 GetPlaylistSongs 的等价导出(只返回歌曲数组) */
export const getPlaylistSongs = async(id: string): Promise<any[]> => {
const detail = await getPlaylistDetail(id, 1, 10000)
return detail.list
}
/** 解析合集/多分P链接(上游 ParsePlaylist;返回详情对象,list 为全量) */
export const parsePlaylistLink = async(link: string) => {
const raw = String(link || '').trim()
const bvid = extractBvid(/^bvid:/i.test(raw) ? raw.replace(/^bvid:/i, '') : raw)
if (!bvid) {
const aid = extractAid(raw)
if (!aid) throw new Error('无效的B站歌单链接:未找到 BV/av 号')
const view = await fetchView(aid)
return parsePlaylistView(view?.bvid || '', view)
}
const view = await fetchView(bvid)
return parsePlaylistView(bvid, view)
}
const parsePlaylistView = async(bvid: string, view: any) => {
const season = view?.ugc_season
if (season) {
const id = seasonKey(season.id, view?.owner?.mid, bvid)
const detail = await detailFromSeason(String(season.id), String(view?.owner?.mid ?? ''), bvid)
let trackCount = countSeasonEpisodes(season.sections)
if (!trackCount) trackCount = detail.songs.length
return {
source: 'bili' as const,
id,
info: { ...detail.info, trackCount, playCount: Number(season?.stat?.view) || undefined },
list: detail.songs,
page: 1,
limit: Math.max(detail.songs.length, 1),
total: detail.songs.length,
}
}
const detail = await detailFromBvid(bvid)
return {
source: 'bili' as const,
id: bvidKey(bvid),
info: { ...detail.info, trackCount: detail.songs.length },
list: detail.songs,
page: 1,
limit: Math.max(detail.songs.length, 1),
total: detail.songs.length,
}
}
/** 上游 GetPlaylistCategories 返回 ErrPlaylistCategoriesUnsupported */
export const getCategories = async(): Promise<any> => {
throw new Error('B站不支持歌单分类(上游未实现)')
}
/** 上游 GetCategoryPlaylists 返回 ErrPlaylistCategoriesUnsupported */
export const getCategoryPlaylists = async(_id: string, _page = 1, _limit = 20): Promise<any> => {
throw new Error('B站不支持歌单分类(上游未实现)')
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
parsePlaylistLink,
getCategories,
getCategoryPlaylists,
}
+307
View File
@@ -0,0 +1,307 @@
// Bilibili 公共请求助手(上游 music-lib/bilibili 的 bilibili.go/song.go/playlist.go 共享逻辑)
// 与上游的差异(2026-10-02 实测):
// 1) 上游硬编码的默认 cookie(buvid3=2E109C72-...; SESSDATA=your_sessdata;)当前会被 B 站风控:
// 带上它请求 x/player/playurl 直接返回 HTTP 412(HTML 拦截页),不带 Cookie 反而正常。
// 因此无登录凭据时不照抄那个默认串,而是先请求一次 www.bilibili.com 首页取新鲜的
// buvid3/b_nut(进程内缓存);首页获取失败则不带 Cookie(实测匿名搜索/取流均可用)。
// 2) httpFetch 不跟随 302,取流的手动跟跳在 sdk/apis/bili.ts 内处理。
// 3) 上游 New(cookie) 的登录凭据来自 sdk/auth/store.ts 的 bili 记录(扫码登录写入)。
import { httpFetch } from '../common/request.ts'
import { getCredentials } from '../auth/store.ts'
import { formatPlayTime } from '../common/utils.ts'
export const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0'
export const REFERER = 'https://www.bilibili.com/'
const HOME_URL = 'https://www.bilibili.com/'
/** 上游 UserAgent/Referer 组合(所有接口统一携带) */
export const biliHeaders = (cookie = ''): Record<string, string> => ({
'User-Agent': UA,
Referer: REFERER,
Accept: 'application/json, text/plain, */*',
...(cookie ? { Cookie: cookie } : {}),
})
/** 已保存的登录 Cookie(未登录返回空串) */
export const getLoginCookie = (): string => String((getCredentials('bili') as any)?.cookie || '')
const cookieMapFromHeaders = (headers: any): Record<string, string> => {
const raw = headers?.['set-cookie']
const values: string[] = Array.isArray(raw) ? raw : raw ? [String(raw)] : []
const jar: Record<string, string> = {}
for (const value of values) {
const pair = String(value).split(';')[0].trim()
const idx = pair.indexOf('=')
if (idx > 0) jar[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim()
}
return jar
}
let anonCookie = ''
let anonCookiePromise: Promise<string> | null = null
/** 请求用 Cookie:登录态优先;否则懒加载一份匿名 buvid3(内存缓存,不落盘) */
export const getCookie = async(): Promise<string> => {
const saved = getLoginCookie()
if (saved) return saved
if (anonCookie) return anonCookie
if (!anonCookiePromise) {
anonCookiePromise = (async() => {
try {
const { headers } = await httpFetch(HOME_URL, { headers: biliHeaders(), timeout: 15000 }).promise as any
const jar = cookieMapFromHeaders(headers)
anonCookie = Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ')
} catch {
// 拿不到就用无 Cookie 请求(实测同样可用)
}
return anonCookie
})()
}
return anonCookiePromise
}
/** 带 B 站请求头的 GET(cookie 省略时自动取登录态/匿名 buvid3;显式传 '' 表示匿名) */
export const apiGet = async(url: string, cookie?: string): Promise<{ statusCode: number, headers: any, body: any }> => {
const ck = cookie === undefined ? await getCookie() : cookie
return httpFetch(url, { headers: biliHeaders(ck), timeout: 15000 }).promise as any
}
/** 封面协议补全(上游 normalizeCover) */
export const normalizeCover = (cover: string): string => {
if (!cover) return ''
return cover.startsWith('//') ? `https:${cover}` : cover
}
/** 搜索标题去高亮标签(上游 cleanTitle) */
export const cleanTitle = (title: string): string => {
if (!title) return ''
return String(title).replace(/<em class="keyword">/g, '').replace(/<\/em>/g, '')
}
/** songmid 约定:`<bvid>|<cid>`(与上游 ID 格式一致) */
export const parseSongId = (songmid: any): { bvid: string, cid: string } => {
const raw = String(songmid ?? '')
const idx = raw.indexOf('|')
if (idx < 0) return { bvid: raw, cid: '' }
return { bvid: raw.slice(0, idx), cid: raw.slice(idx + 1) }
}
/** 从链接/id 提取 BV 号(上游正则 `(BV\w+)` 的等价实现,兼容小写 bv) */
export const extractBvid = (link: string): string => String(link || '').match(/BV[0-9A-Za-z]+/i)?.[0] || ''
/** 从链接提取分P(上游 `[?&]p=(\d+)`) */
export const extractPage = (link: string): number => {
const m = String(link || '').match(/[?&]p=(\d+)/)
const p = m ? parseInt(m[1], 10) : 0
return p > 0 ? p : 1
}
/** 从链接提取 av 号(上游未支持,任务要求兼容) */
export const extractAid = (link: string): string => {
const m = String(link || '').match(/\bav(\d+)\b/i)
return m ? m[1] : ''
}
/** 并发映射(限制并发数,单条失败返回 null) */
export const mapLimit = async(items: any[], limit: number, fn: (item: any, index: number) => Promise<any>): Promise<any[]> => {
const list = Array.isArray(items) ? items : []
const out: any[] = new Array(list.length).fill(null)
let next = 0
const size = Math.max(1, Math.min(Number(limit) || 1, list.length || 1))
await Promise.all(Array.from({ length: size }, async() => {
while (next < list.length) {
const i = next++
try {
out[i] = await fn(list[i], i)
} catch {
out[i] = null
}
}
}))
return out
}
/** 统一歌曲形状(对齐 MusicServer 契约;albumId/albumName 沿用上游的 bvid) */
export const makeSong = ({ bvid, cid, name, artist, album, duration, cover, page = 1 }: {
bvid: string
cid: string | number
name?: string
artist?: string
album?: string
duration?: number | string
cover?: string
page?: number
}) => {
const seconds = Number(duration) || 0
const url = `https://www.bilibili.com/video/${bvid}${page > 1 ? `?p=${page}` : ''}`
return {
songmid: `${bvid}|${cid}`,
name: name || '',
singer: artist || '',
albumId: bvid || '',
albumName: album || bvid || '',
interval: seconds ? formatPlayTime(seconds) : 0,
img: normalizeCover(cover || '') || null,
lrc: null,
types: [] as any[],
_types: {} as Record<string, any>,
source: 'bili' as const,
bvid,
cid: String(cid ?? ''),
page,
link: url,
}
}
// ---- 视频/分P/合集接口(上游 bilibili.go 各 fetch* 的对应实现) ----
/** 视频详情(x/web-interface/view);bvid 或 aid 均可,返回 data 节点 */
export const fetchView = async(id: string): Promise<any> => {
const raw = String(id || '').trim()
if (!raw) throw new Error('B站视频 id 为空')
const query = /^BV/i.test(raw) ? `bvid=${encodeURIComponent(raw)}` : `aid=${encodeURIComponent(raw.replace(/^av/i, ''))}`
const { statusCode, body } = await apiGet(`https://api.bilibili.com/x/web-interface/view?${query}`)
if (statusCode !== 200 || !body || body.code !== 0 || !body.data) {
throw new Error(`B站视频信息获取失败(${body?.code ?? `HTTP ${statusCode}`}${body?.message ? `: ${body.message}` : ''})`)
}
return body.data
}
/** 分P列表(x/player/pagelist),返回 data 数组 */
export const fetchPageList = async(bvid: string): Promise<any[]> => {
const { statusCode, body } = await apiGet(`https://api.bilibili.com/x/player/pagelist?bvid=${encodeURIComponent(bvid)}`)
if (statusCode !== 200 || !body || body.code !== 0) {
throw new Error(`B站分P列表获取失败(${body?.code ?? `HTTP ${statusCode}`}${body?.message ? `: ${body.message}` : ''})`)
}
return body.data ?? []
}
/** 合集归档索引(x/space/ugc/season;上游 fetchSeasonArchiveIndex;该接口现已 404,调用方需容错) */
export const fetchSeasonArchiveIndex = async(mid: string | number, seasonId: string | number): Promise<{ index: Record<string, { title: string, cover: string, duration: number }>, title: string, cover: string }> => {
if (!mid || !seasonId) throw new Error('合集信息无效')
const index: Record<string, { title: string, cover: string, duration: number }> = {}
let seasonTitle = ''
let seasonCover = ''
let processed = 0
let pageNum = 1
const pageSize = 30
for (;;) {
const { body } = await apiGet(`https://api.bilibili.com/x/space/ugc/season?mid=${mid}&season_id=${seasonId}&page_num=${pageNum}&page_size=${pageSize}`)
if (!body || body.code !== 0) {
throw new Error(`B站合集信息获取失败(${body?.code ?? '非 JSON 响应'}${body?.message ? `: ${body.message}` : ''})`)
}
if (!seasonTitle) seasonTitle = body.data?.season?.title || ''
if (!seasonCover) seasonCover = body.data?.season?.cover || ''
const archives = body.data?.archives ?? []
if (!archives.length) break
for (const arc of archives) {
const meta = { title: arc?.title || '', cover: normalizeCover(arc?.cover || ''), duration: Number(arc?.duration) || 0 }
if (arc?.bvid) index[`bvid:${arc.bvid}`] = meta
if (arc?.cid) index[`cid:${arc.cid}`] = meta
}
processed += archives.length
const total = Number(body.data?.page?.total) || 0
if (!total || processed >= total) break
pageNum += 1
if (pageNum > 100) break
}
return { index, title: seasonTitle, cover: seasonCover }
}
/** 由分P数组构造歌曲(上游 buildSongsFromPages) */
export const buildSongsFromPages = (bvid: string, rootTitle: string, author: string, cover: string, pages: any[]): any[] => {
return (pages || []).map((page: any, i: number) => {
const part = page?.part || ''
let displayTitle = part
if (pages.length === 1 && !displayTitle) displayTitle = rootTitle
else if (displayTitle !== rootTitle) displayTitle = `${rootTitle} - ${displayTitle}`
return makeSong({
bvid,
cid: page?.cid,
name: displayTitle,
artist: author,
album: bvid,
duration: page?.duration,
cover,
page: i + 1,
})
})
}
/** 由合集 sections 构造歌曲(上游 buildSongsFromSeasonSections) */
export const buildSongsFromSeasonSections = (
sections: any[],
seasonTitle: string,
seasonCover: string,
artistName: string,
archiveIndex: Record<string, { title: string, cover: string, duration: number }> = {},
): any[] => {
const songs: any[] = []
const artist = artistName || seasonTitle
for (const sec of sections || []) {
for (const ep of sec?.episodes || []) {
let cover = ep?.cover || ''
const meta = archiveIndex[`bvid:${ep?.bvid}`] ?? (ep?.cid ? archiveIndex[`cid:${ep.cid}`] : undefined)
if (!cover) cover = ep?.arc?.pic || ''
if (!cover) cover = seasonCover || meta?.cover || ''
let duration = Number(ep?.duration) || 0
if (!duration) duration = Number(ep?.page?.duration) || 0
if (!duration) duration = Number(ep?.arc?.duration) || 0
if (!duration) duration = meta?.duration || 0
let name = ep?.title || ep?.arc?.title || ep?.page?.part || meta?.title || ''
songs.push(makeSong({
bvid: ep?.bvid || '',
cid: ep?.cid,
name,
artist,
album: ep?.bvid || '',
duration,
cover,
page: 1,
}))
}
}
return songs
}
/** 合集歌曲(x/space/ugc/season,上游 fetchSeasonSongs;该接口现已 404,调用方需容错) */
export const fetchSeasonSongs = async(mid: string | number, seasonId: string | number): Promise<any[]> => {
if (!mid || !seasonId) throw new Error('合集信息无效')
const all: any[] = []
let processed = 0
let seasonTitle = ''
let seasonCover = ''
let pageNum = 1
const pageSize = 30
for (;;) {
const { body } = await apiGet(`https://api.bilibili.com/x/space/ugc/season?mid=${mid}&season_id=${seasonId}&page_num=${pageNum}&page_size=${pageSize}`)
if (!body || body.code !== 0) {
throw new Error(`B站合集列表获取失败(${body?.code ?? '非 JSON 响应'}${body?.message ? `: ${body.message}` : ''})`)
}
if (!seasonTitle) seasonTitle = body.data?.season?.title || ''
if (!seasonCover) seasonCover = body.data?.season?.cover || ''
const archives = body.data?.archives ?? []
if (!archives.length) break
for (const arc of archives) {
if (!arc?.cid) continue
all.push(makeSong({
bvid: arc?.bvid || '',
cid: arc.cid,
name: arc?.title || '',
artist: '',
album: seasonTitle || arc?.bvid || '',
duration: arc?.duration,
cover: normalizeCover(arc?.cover || seasonCover),
page: 1,
}))
}
processed += archives.length
const total = Number(body.data?.page?.total) || 0
if (!total || processed >= total) break
pageNum += 1
if (pageNum > 100) break
}
return all
}