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

+316
View File
@@ -0,0 +1,316 @@
// JOOX 歌单(上游 joox/playlist.go 移植:OpenJOOX 接口 + 网页数据兜底两条路)
// 搜索:GET https://cache.api.joox.com/openjoox/v3/search(item.type=1 editor_playlist)
// 曲目:GET https://cache.api.joox.com/openjoox2/v1/playlist/<id>/tracks?...&secret= ← 新版 web 播放器通道
// (上游 playlist.go 里的 openjoox/v3/playlist 已 404;网页兜底仍保留)
// 兜底:www.joox.com/{id,sg,hk}/playlist/<id> SSR __NEXT_DATA__
// props.pageProps.allPlaylistTracks / playlistDetailList(含 content.page 嵌套变体)
// 分类:www.joox.com/sg/playlist(302 → /<region> 首页)__NEXT_DATA__.props.pageProps.mlList
// 链接解析:joox.com/{region}/playlist/<id>、playlistid/id 查询参数、裸 ID
import { httpFetch } from '../request.ts'
import {
jooxHeaders,
jooxSign,
jooxPlaylistLink,
jooxPlaylistLinks,
normalizeJooxID,
normalizeJooxLang,
pickJooxImage,
firstNonEmpty,
firstNonZero,
decodeJooxBase64Text,
extractJooxNextData,
fetchJooxPage,
} from './util.ts'
import { JOOX_OPENJOOX2 } from './musicInfo.ts'
import { jooxSongFromTrack } from './song.ts'
const SEARCH_URL = 'https://cache.api.joox.com/openjoox/v3/search'
const CATEGORY_PAGE = 'https://www.joox.com/sg/playlist'
const mapPlaylistSummary = (item: any) => {
const id = normalizeJooxID(item?.id)
if (!id) return null
return {
id,
name: String(item?.name ?? ''),
cover: pickJooxImage(item?.images) || null,
trackCount: 0,
creator: '',
description: '',
link: jooxPlaylistLink(id),
source: 'joox' as const,
}
}
/** openjoox2 歌单曲目(含歌单元信息) */
const fetchPlaylistTracks = async(playlistId: string, page: number, limit: number) => {
const country = 'hk'
const lang = normalizeJooxLang('zh_hk')
const index = Math.max(0, (page - 1) * limit)
const params = { country, lang, index, num: limit }
const url = `${JOOX_OPENJOOX2}/playlist/${playlistId}/tracks?country=${country}&lang=${lang}&index=${index}&num=${limit}&secret=${jooxSign(params)}`
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
if (statusCode != 200 || !body || body.code) return null
const list = (body?.tracks?.items ?? [])
.map(item => jooxSongFromTrack(item, String(body?.name ?? ''), pickJooxImage(body?.images)))
.filter(Boolean)
if (!list.length) return null
const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, body?.track_count, list.length)
return {
source: 'joox' as const,
info: {
name: String(firstNonEmpty(body?.name, playlistId)),
img: pickJooxImage(body?.images) || null,
desc: String(firstNonEmpty(body?.description, body?.publish_date)),
author: '',
},
list,
page,
limit,
total,
}
}
/** 网页兜底:解析歌单 SSR 数据(上游 fetchPlaylistPageDataFromURL 的移植) */
const parsePlaylistPageData = (nextData: any, pageUrl: string) => {
const pageProps = nextData?.props?.pageProps ?? {}
let detail: any = pageProps.playlistDetailList ?? {}
let tracks: any[] = pageProps.allPlaylistTracks?.tracks?.items ?? []
let trackCount = firstNonZero(
pageProps.allPlaylistTracks?.tracks?.total_count,
pageProps.allPlaylistTracks?.tracks?.totalCount,
pageProps.allPlaylistTracks?.tracks?.list_count,
)
if (!tracks.length) {
tracks = detail?.trackList ?? []
trackCount = firstNonZero(trackCount, detail?.trackCount, detail?.totalCount, detail?.total_count, detail?.list_count)
}
if (!tracks.length) {
detail = pageProps.content?.page?.playlistDetailList ?? {}
tracks = pageProps.content?.page?.allPlaylistTracks?.tracks?.items ?? []
trackCount = firstNonZero(
pageProps.content?.page?.allPlaylistTracks?.tracks?.total_count,
pageProps.content?.page?.allPlaylistTracks?.tracks?.totalCount,
pageProps.content?.page?.allPlaylistTracks?.tracks?.list_count,
trackCount,
)
if (!tracks.length) {
tracks = detail?.trackList ?? []
trackCount = firstNonZero(trackCount, detail?.trackCount, detail?.totalCount, detail?.total_count, detail?.list_count)
}
}
if (!tracks.length) return null
const id = normalizeJooxID(firstNonEmpty(detail?.id, detail?.listId, detail?.playlistId))
const name = String(firstNonEmpty(detail?.name, detail?.title, detail?.playlistName, id))
const cover = String(firstNonEmpty(detail?.imgSrc, detail?.cover, detail?.image, detail?.pic))
const creator = firstNonEmpty(
detail?.creator, detail?.creatorName, detail?.userName, detail?.nickName, detail?.ownerName,
detail?.author, detail?.authorName,
detail?.user?.name, detail?.user?.userName, detail?.user?.nickName, detail?.user?.nick,
detail?.owner?.name, detail?.owner?.userName, detail?.owner?.nickName, detail?.owner?.nick,
detail?.creatorInfo?.name, detail?.creatorInfo?.userName, detail?.creatorInfo?.nickName, detail?.creatorInfo?.nick,
)
const description = firstNonEmpty(detail?.description, detail?.intro, detail?.desc)
return { id, name, cover, creator, description, trackCount, tracks, pageUrl }
}
/** 网页兜底:逐个区域尝试歌单页(上游 fetchPlaylistPageData / ParsePlaylist 的页面路径) */
export const getPlaylistDetailFromPage = async(rawId: string) => {
const playlistId = normalizeJooxID(rawId)
if (!playlistId) throw new Error('joox 歌单 id 无效')
let lastErr: any = null
for (const pageUrl of jooxPlaylistLinks(playlistId)) {
try {
const { body: html } = await fetchJooxPage(pageUrl)
const nextData = extractJooxNextData(html)
const parsed = parsePlaylistPageData(nextData, pageUrl)
if (!parsed) throw new Error('joox 歌单页数据不存在')
const pageId = normalizeJooxID(parsed.id) || playlistId
const cover = String(parsed.cover || '')
const list = parsed.tracks
.map(item => jooxSongFromTrack(item, parsed.name, cover))
.filter(Boolean)
if (!list.length) throw new Error('joox 歌单无歌曲')
return {
source: 'joox' as const,
info: {
name: String(parsed.name || pageId),
img: cover || null,
desc: String(parsed.description || ''),
author: String(parsed.creator || ''),
},
list,
page: 1,
limit: list.length,
total: firstNonZero(parsed.trackCount, list.length),
}
} catch (err) {
lastErr = err
}
}
throw lastErr ?? new Error('joox 歌单页数据不存在')
}
/** 歌单详情:openjoox2 通道优先,网页兜底(上游两条路都保留) */
export const getPlaylistDetail = async(rawId: string, page = 1, limit = 50) => {
const playlistId = normalizeJooxID(rawId)
if (!playlistId) throw new Error('joox 歌单 id 无效')
try {
const detail = await fetchPlaylistTracks(playlistId, page, limit)
if (detail) return detail
} catch {
// 通道不可用时走网页兜底
}
return getPlaylistDetailFromPage(playlistId)
}
/** 歌单曲目(上游 GetPlaylistSongs:仅曲目列表;v3 接口 404 时用网页兜底) */
export const getPlaylistSongs = async(id: string, page = 1, limit = 50) => {
try {
const detail = await fetchPlaylistTracks(normalizeJooxID(id), page, limit)
if (detail) return detail.list
} catch {
// 走网页兜底
}
const { list } = await getPlaylistDetailFromPage(normalizeJooxID(id))
return list
}
/** 歌单搜索(上游 SearchPlaylist) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}`
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
if (statusCode != 200 || !body?.section_list) {
return { list: [], total: 0, page, limit, source: 'joox' as const }
}
const list: any[] = []
const seen = new Set<string>()
for (const section of body.section_list) {
for (const item of section?.item_list ?? []) {
if (Number(item?.type) !== 1) continue
const summary = mapPlaylistSummary(item?.editor_playlist)
if (!summary || seen.has(summary.id)) continue
seen.add(summary.id)
list.push(summary)
}
}
return { list: list.slice(0, limit), total: list.length, page, limit, source: 'joox' as const }
}
/** 歌单分类页原始数据(上游 fetchJooxPlaylistCategoriesPage) */
const fetchPlaylistCategoriesPage = async() => {
const { body: html } = await fetchJooxPage(CATEGORY_PAGE)
const nextData = extractJooxNextData(html)
const mlList = nextData?.props?.pageProps?.mlList
if (!mlList?.category?.length) throw new Error('joox 歌单分类不存在')
return mlList
}
/** 分类 id:上游 jooxCategoryPlaylistID(数字/字符串都归一) */
const categoryPlaylistId = (value: unknown): string => {
if (value == null) return ''
if (typeof value === 'number') return String(Math.trunc(value))
return normalizeJooxID(value)
}
/** 分类列表(上游 GetPlaylistCategories;首个为「全部」) */
export const getCategories = async() => {
const data = await fetchPlaylistCategoriesPage()
const list: any[] = [{ id: '', name: '全部', group: '全部', hot: false }]
data.category.forEach((category: any, index: number) => {
const name = decodeJooxBase64Text(category?.title)
const items: any[] = category?.itemlist ?? []
if (!name || !items.length) return
list.push({ id: String(index), name, group: 'JOOX', hot: false, count: items.length })
})
if (list.length === 1) throw new Error('joox 歌单分类不存在')
return { source: 'joox' as const, list }
}
/** 分类下歌单(上游 GetCategoryPlaylists) */
export const getCategoryPlaylists = async(categoryId = '', page = 1, limit = 20) => {
const data = await fetchPlaylistCategoriesPage()
const cid = String(categoryId ?? '').trim()
let items: any[] = []
let categoryName = '全部'
if (!cid) {
const seen = new Set<string>()
for (const category of data.category) {
for (const item of category?.itemlist ?? []) {
const id = categoryPlaylistId(item?.id)
if (!id || seen.has(id)) continue
seen.add(id)
items.push(item)
}
}
} else {
for (let index = 0; index < data.category.length; index++) {
const category = data.category[index]
const name = decodeJooxBase64Text(category?.title)
if (String(index) === cid || (name && name.toLowerCase() === cid.toLowerCase())) {
items = category?.itemlist ?? []
categoryName = name
break
}
}
if (!items.length) throw new Error('joox 歌单分类不存在')
}
const start = Math.max(0, (page - 1) * limit)
if (start >= items.length) throw new Error('joox 分类下无歌单')
const pageItems = items.slice(start, start + limit)
const list = pageItems
.map(item => {
const id = categoryPlaylistId(item?.id)
const name = decodeJooxBase64Text(item?.title)
if (!id || !name) return null
return {
id,
name,
cover: String(item?.picurl ?? '').replace('%d', '300') || null,
trackCount: 0,
creator: '',
description: '',
link: jooxPlaylistLink(id),
categoryId: cid,
categoryName,
source: 'joox' as const,
}
})
.filter(Boolean)
if (!list.length) throw new Error('joox 分类下无歌单')
return { list, total: items.length, page, limit, source: 'joox' as const }
}
/** 歌单链接解析(上游 ParsePlaylist) */
export const parsePlaylist = async(link: string) => {
const patterns = [
/joox\.com\/.*\/playlist\/([^/?#]+)/,
/(?:playlistid|playlist_id|id)=([^&]+)/,
]
for (const pattern of patterns) {
const m = pattern.exec(String(link ?? ''))
if (m) return getPlaylistDetail(m[1])
}
const raw = String(link ?? '').trim()
if (raw.length > 8 && !raw.includes('/')) return getPlaylistDetail(raw)
throw new Error('invalid joox playlist link')
}
export default {
getPlaylistDetail,
getPlaylistSongs,
searchPlaylist,
getCategories,
getCategoryPlaylists,
parsePlaylist,
}