Files
MusicServer/sdk/bili/playlist.ts
T
snowy-yang 3381e9b906 refactor(api)!: 平台 id 改两字母短码——qian→bd、joox→jx、5sing→5s、bili→bl
对外 id 统一为两字母短码,与既有的 kw/kg/tx/wy/qs/xm/me/qt/lr 对齐;
migu 与 apple 保持原样(Apple Music 没有自然的短码)。

**破坏性变更**:id 出现在所有 URL、/sources 返回、OpenAPI 示例以及
data/auth.json 的凭据键里,这四个平台已存的登录态会失效,需重新登录。

- sdk/index.ts:id 与 supportQuality 映射键
- sdk/types.ts:Source 联合类型
- src/routes.ts 与 routes-login.ts:路由路径与 schema 描述
  (/login/bili/qr → /login/bl/qr 等)、/login/status 的响应键
- test/smoke-test.ts、README.md、docs/API.md 同步

有意未改:sdk 目录与文件名仍用平台名(sdk/qian/、sdk/apis/joox.ts …),
规则定为「目录=平台名,id=对外短码」,映射只在 SUPPORTED_SOURCES 一处声明。
域名与正则(joox.com、5sing.kugou.com、/joox\.com\//)、jooxSign 等标识符、
错误文案里的中文平台名、以及 kg 榜单名(含「joox本地热歌榜」)全部保持原样——
它们只是恰好含平台名,并不是 id。

验证:typecheck ✓、pnpm test 112/112 ✓;另起临时实例实证 /sources 与
/login/status 的新 id 与展示名,新旧 id 均无残留。
2026-10-03 11:03:06 +08:00

258 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: 'bl' 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: 'bl' as const,
}
}
return null
})
const list = result.filter(Boolean)
return { list, page: p, limit: l, total: list.length, source: 'bl' 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: 'bl' 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: 'bl' 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: 'bl' 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,
}