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

+180
View File
@@ -0,0 +1,180 @@
// 千千音乐专辑
// 搜索:/v1/search?type=3(特殊字符触发 errno 23001 时用清洗后的关键词重试,见 searchAlbumItems)
// 详情:/v1/album/info?albumAssetCode=P...(P 码可直接用;纯数字旧 id 走 /v1/album/albumid2psid 转换)
// 专辑链接:https://music.91q.com/album/<albumAssetCode>
import { APP_ID, apiGet, apiOk, joinArtists, normalizeAlbumAssetCode, mapTrack, releaseDate10, sanitizeAlbumKeyword, albumLink } from './util.ts'
export interface QianAlbumSearchItem {
albumAssetCode?: string
title?: string
pic?: string
introduce?: string
releaseDate?: string
genre?: string
lang?: string
artist?: any[]
trackList?: any[]
}
const mapAlbumSummary = (item: QianAlbumSearchItem) => {
const id = normalizeAlbumAssetCode(item?.albumAssetCode)
return {
id,
name: String(item?.title ?? '').trim(),
cover: String(item?.pic ?? '').trim(),
trackCount: Array.isArray(item?.trackList) ? item.trackList.length : 0,
creator: joinArtists(item?.artist),
description: String(item?.introduce ?? '').trim(),
releaseDate: releaseDate10(item?.releaseDate),
genre: String(item?.genre ?? '').trim(),
lang: String(item?.lang ?? '').trim(),
link: id ? albumLink(id) : '',
source: 'qian' as const,
}
}
/** 单次专辑搜索;23001(词非法)时抛出带 retryWithSanitized 标记的错误 */
const searchAlbumOnce = async(word: string, page = 1, limit = 10) => {
const body = await apiGet('/v1/search', {
word,
type: '3',
pageNo: String(Math.max(1, page)),
pageSize: String(Math.max(1, limit)),
appid: APP_ID,
})
if (!apiOk(body)) {
const err: any = new Error(body?.errmsg || `千千专辑搜索失败(errno ${body?.errno})`)
err.retryWithSanitized = Number(body?.errno) === 23001
throw err
}
const list = body?.data?.typeAlbum
return Array.isArray(list) ? (list as QianAlbumSearchItem[]) : []
}
/** 上游 searchAlbumItems:原词失败(23001)时用 sanitizeQianqianAlbumKeyword 清洗后重试一次 */
export const searchAlbumItems = async(keyword: string, page = 1, limit = 10): Promise<QianAlbumSearchItem[]> => {
const words = [String(keyword ?? '').trim()]
const sanitized = sanitizeAlbumKeyword(keyword)
if (sanitized && sanitized !== words[0]) words.push(sanitized)
let lastErr: Error | null = null
for (const word of words) {
try {
return await searchAlbumOnce(word, page, limit)
} catch (err: any) {
lastErr = err
// 仅 23001 允许换清洗后的词重试;其余错误(网络/服务端)直接上抛
if (!err?.retryWithSanitized) break
}
}
if (lastErr) throw lastErr
return []
}
/** 搜索专辑(返回歌单/专辑统一摘要形状) */
export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
const word = String(keyword ?? '').trim()
const empty = { source: 'qian' as const, list: [] as any[], total: 0, page, limit }
if (!word) return empty
const items = await searchAlbumItems(word, page, limit)
const list = items
.map(mapAlbumSummary)
.filter((album) => album.id)
.slice(0, limit)
return { source: 'qian' as const, list, total: items.length, page, limit }
}
/** 旧数字专辑 id → PSID(P 码);已是 P 码则原样返回 */
export const resolveAlbumAssetCode = async(id: string): Promise<string> => {
const raw = String(id ?? '').trim()
if (!raw) throw new Error('千千专辑 id 为空')
const normalized = normalizeAlbumAssetCode(raw)
if (normalized) return normalized
const body = await apiGet('/v1/album/albumid2psid', { albumid: raw, appid: APP_ID })
if (!apiOk(body)) throw new Error(body?.errmsg || `千千专辑 id 转换失败(errno ${body?.errno})`)
for (const item of Array.isArray(body?.data) ? body.data : []) {
const psid = normalizeAlbumAssetCode(item?.psid)
if (psid) return psid
}
throw new Error('千千专辑 asset code 未找到')
}
const fetchAlbumInfo = async(id: string) => {
const albumId = await resolveAlbumAssetCode(id)
const body = await apiGet('/v1/album/info', { albumAssetCode: albumId, appid: APP_ID })
if (!apiOk(body)) throw new Error(body?.errmsg || `千千专辑详情获取失败(errno ${body?.errno})`)
const data = body?.data ?? {}
if (!normalizeAlbumAssetCode(data?.albumAssetCode)) throw new Error('千千专辑不存在或已下架')
return { albumId, data }
}
/** 专辑详情(MusicServer 统一形状;album/info 一次返回全部曲目,这里按 page/limit 切片) */
export const getAlbumDetail = async(id: string, page = 1, limit = 30) => {
const { albumId, data } = await fetchAlbumInfo(id)
const title = String(data?.title ?? '').trim()
const cover = String(data?.pic ?? '').trim()
const creator = joinArtists(data?.artist)
const all = Array.isArray(data?.trackList) ? data.trackList : []
const list = all
.map((item: any) => {
const song = mapTrack(item, { albumId, albumName: title, img: cover, singer: creator })
return {
...song,
// 专辑内曲目补上时长/序号(上游 Extra.track / sort)
track: Number(item?.sort) > 0 ? Number(item.sort) : undefined,
}
})
.filter((song: any) => song.songmid)
const start = (Math.max(1, page) - 1) * Math.max(1, limit)
const paged = list.slice(start, start + Math.max(1, limit))
const albumInfo = {
id: albumId,
name: title,
img: cover,
desc: String(data?.introduce ?? '').trim(),
author: creator,
releaseDate: releaseDate10(data?.releaseDate),
genre: String(data?.genre ?? '').trim(),
lang: String(data?.lang ?? '').trim(),
total: list.length,
}
return {
source: 'qian' as const,
info: albumInfo,
list: paged,
page,
limit,
total: list.length,
allPage: Math.ceil(list.length / Math.max(1, limit)) || 1,
}
}
/** 上游 GetAlbumSongs:只取曲目列表 */
export const getAlbumSongs = async(id: string) => {
const { list } = await getAlbumDetail(id, 1, 10000)
return list
}
/** 上游 ParseAlbum:支持 P 码链接 / albumAssetCode= / albumid= 三种形态 */
export const parseAlbumLink = async(link: string) => {
const raw = String(link ?? '').trim()
const patterns = [
/music\.91q\.com\/album\/([A-Za-z0-9]+)/,
/albumAssetCode=([A-Za-z0-9]+)/,
/albumid=(\d+)/,
]
for (const pattern of patterns) {
const match = pattern.exec(raw)
if (match) return getAlbumDetail(match[1])
}
throw new Error('无效的千千专辑链接')
}
export default { searchAlbum, searchAlbumItems, getAlbumDetail, getAlbumSongs, parseAlbumLink, resolveAlbumAssetCode }