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