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

+169
View File
@@ -0,0 +1,169 @@
// JOOX 专辑(上游 joox/album.go 移植 + 新版 openjoox2 通道)
// 搜索:GET https://cache.api.joox.com/openjoox/v3/search(section_type=1 专辑区,item.type=2 专辑)
// 曲目:GET https://cache.api.joox.com/openjoox2/v1/album/<id>/tracks?country=hk&lang=zh_TW&index=&num=&secret=
// (web 播放器通道,需 md5 签名;实测 2026-10-02 HTTP 200)
// 兜底:www.joox.com/<region>/album/<id> SSR __NEXT_DATA__.props.pageProps.albumData(上游做法,需 X-Forwarded-For)
// 链接解析:ParseAlbum 的正则与裸 ID 规则同上游。
import { httpFetch } from '../request.ts'
import {
jooxHeaders,
jooxSign,
jooxAlbumLink,
normalizeJooxID,
normalizeJooxLang,
pickJooxImage,
jooxArtists,
firstNonEmpty,
firstNonZero,
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'
/** openjoox2 专辑曲目(含专辑元信息) */
const fetchAlbumTracks = async(albumId: 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}/album/${albumId}/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 tracks: any[] = body?.tracks?.items ?? []
const list = tracks.map(item => jooxSongFromTrack(item, body?.name, pickJooxImage(body?.images), albumId)).filter(Boolean)
if (!list.length) return null
const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, list.length)
return {
source: 'joox' as const,
info: {
name: String(firstNonEmpty(body?.name, albumId)),
img: pickJooxImage(body?.images) || null,
desc: String(firstNonEmpty(body?.description, body?.publish_date)),
author: jooxArtists(body?.artist_list),
},
list,
page,
limit,
total,
}
}
/** SSR 页面专辑数据(上游 fetchAlbumPageData + fetchAlbumDetail 的 data 部分) */
const fetchAlbumPageData = async(albumId: string) => {
const { body: html } = await fetchJooxPage(jooxAlbumLink(albumId))
const nextData = extractJooxNextData(html)
const pageProps = nextData?.props?.pageProps
const albumData = pageProps?.albumData
if (normalizeJooxID(albumData?.id)) return albumData
const nested = pageProps?.content?.page?.albumData
if (normalizeJooxID(nested?.id)) return nested
throw new Error('joox 专辑页数据不存在')
}
/** 专辑详情(openjoox2 优先,SSR 页兜底) */
export const getAlbumDetail = async(rawId: string, page = 1, limit = 50) => {
const albumId = normalizeJooxID(rawId)
if (!albumId) throw new Error('joox 专辑 id 无效')
try {
const detail = await fetchAlbumTracks(albumId, page, limit)
if (detail) return detail
} catch {
// 通道不可用时走页面兜底
}
return getAlbumDetailFromPage(albumId, page, limit)
}
/** 专辑详情(SSR 网页兜底通道,上游 fetchAlbumDetail 的页面路径) */
export const getAlbumDetailFromPage = async(rawId: string, page = 1, limit = 50) => {
const albumId = normalizeJooxID(rawId)
if (!albumId) throw new Error('joox 专辑 id 无效')
const albumData = await fetchAlbumPageData(albumId)
const items: any[] = albumData?.trackList?.items ?? []
const pageStart = Math.max(0, (page - 1) * limit)
const pageItems = items.slice(pageStart, pageStart + limit)
const cover = String(firstNonEmpty(pickJooxImage(albumData?.images), albumData?.imgSrc))
const list = pageItems
.map(item => jooxSongFromTrack(item, albumData?.title, cover, albumId))
.filter(Boolean)
if (!list.length) throw new Error('joox 专辑无歌曲')
const total = firstNonZero(albumData?.trackList?.total_count, albumData?.trackList?.list_count, items.length)
return {
source: 'joox' as const,
info: {
name: String(firstNonEmpty(albumData?.title, albumId)),
img: cover || null,
desc: String(firstNonEmpty(albumData?.description, albumData?.publishDate)),
author: jooxArtists(albumData?.artistList),
},
list,
page,
limit,
total,
}
}
/** 专辑曲目(上游 GetAlbumSongs:仅曲目列表) */
export const getAlbumSongs = async(id: string, page = 1, limit = 50) => {
const { list } = await getAlbumDetail(id, page, limit)
return list
}
/** 专辑搜索(上游 SearchAlbum) */
export const searchAlbum = 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) {
if (Number(section?.section_type) !== 1) continue
for (const item of section?.item_list ?? []) {
if (Number(item?.type) !== 2) continue
const album = item?.album ?? {}
const id = normalizeJooxID(album.id)
if (!id || seen.has(id)) continue
seen.add(id)
list.push({
id,
name: String(album.name ?? ''),
cover: pickJooxImage(album.images) || null,
creator: jooxArtists(album.artist_list),
description: String(album.publish_date ?? '').trim(),
publishDate: String(album.publish_date ?? '').trim(),
trackCount: 0,
link: jooxAlbumLink(id),
source: 'joox' as const,
})
}
}
return { list: list.slice(0, limit), total: list.length, page, limit, source: 'joox' as const }
}
/** 专辑链接解析(上游 ParseAlbum) */
export const parseAlbum = async(link: string) => {
const patterns = [
/joox\.com\/.*\/album\/([^/?#]+)/,
/h_activity_id=([^&]+)/,
/albumid=([^&]+)/,
]
for (const pattern of patterns) {
const m = pattern.exec(String(link ?? ''))
if (m) return getAlbumDetail(m[1])
}
const raw = String(link ?? '').trim()
if (raw.length > 10 && !raw.includes('/')) return getAlbumDetail(raw)
throw new Error('invalid joox album link')
}
export default { getAlbumDetail, getAlbumSongs, searchAlbum, parseAlbum }
+62
View File
@@ -0,0 +1,62 @@
// JOOX(腾讯海外音乐,东南亚)平台模块
// 匿名可用面(2026-10-02 实测,需 X-Forwarded-For 东南亚 IP 绕过 www 地区重定向):
// 搜索 / 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+链接解析)/ 歌词 / 链接解析(歌曲)/
// 取流(游客 128k MP3,VIP 曲目为 preview/refrain 片段)。
// 通道:
// - 搜索/专辑搜索/歌单搜索:cache.api.joox.com/openjoox/v3/search(无签名)
// - 取流/歌曲详情/专辑曲目/歌单曲目:cache.api.joox.com/openjoox2/v1(需 md5 签名,见 util.jooxSign)
// - 歌词:api.joox.com/web-fcgi-bin/web_lyric(旧接口仍可用;失败回退 openjoox2 lrc_content)
// - 分类/歌单/专辑 SSR 兜底:www.joox.com __NEXT_DATA__
// 上游 New(cookie):默认游客 Cookie 来自 joox.go 常量;/login/joox 绑定后从 sdk/auth/store.ts 读取,
// 登录 cookie 的 wmid/session_key 会作为 uid/usk 参与 openjoox2 签名(VIP 完整曲目依赖它)。
// userPlaylist:上游 joox/user_playlist.go 返回 ErrUserPlaylistsUnsupported,未实现(不造)。
import jooxApi from '../apis/joox.ts'
import musicSearch from './musicSearch.ts'
import album from './album.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import lyric from './lyric.ts'
import { getTrackDetail } from './musicInfo.ts'
import { pickJooxImage, normalizeJooxID, jooxSongLink } from './util.ts'
import { getCredentials } from '../auth/store.ts'
const joox = {
musicSearch,
getMusicUrl(songInfo, type) {
return jooxApi.getMusicUrl(songInfo, type)
},
getLyric(songInfo, ...rest) {
return lyric.getLyric(songInfo, ...rest)
},
getPic(songInfo) {
const songId = normalizeJooxID(songInfo?.songmid ?? songInfo?.id ?? '')
return getTrackDetail(songId).then(detail => pickJooxImage(detail?.images) || null)
},
album,
playlist,
// 契约别名:JOOX 的「分类/列表」能力即 playlist.go 的歌单分类,没有独立的 songList 接口,
// 这里把同一套分类/分类歌单挂到 songList 键,便于 /song-list/joox 路由复用。
songList: {
sortList: [{ name: '全部', id: '' }],
getTags() {
return playlist.getCategories()
},
getList(sortId: string, tagId: string, page = 1, limit = 30) {
return playlist.getCategoryPlaylists(tagId ?? sortId ?? '', page, limit)
},
},
parse,
getMusicDetailPageUrl(songInfo) {
return jooxSongLink(songInfo?.songmid ?? songInfo?.id ?? '')
},
}
/** 登录状态(凭据存在性判断;无在线校验接口) */
export const status = () => {
const cred = getCredentials('joox') as { cookie?: string, updatedAt?: string } | null
return cred?.cookie
? { logged: true, updatedAt: cred.updatedAt }
: { logged: false }
}
export default joox
+84
View File
@@ -0,0 +1,84 @@
// JOOX 歌词(上游 joox/lyric.go 移植)
// 端点:GET https://api.joox.com/web-fcgi-bin/web_lyric?musicid=<id>&country=sg&lang=zh_cn
// 响应形如 MusicJsonCallback({"code":0,"lyric":"<base64>"}),lyric 为 base64 UTF-8 歌词。
// 实测(2026-10-02)该旧接口仍可用(web_get_songinfo 已 404,但 web_lyric 未下线)。
// 兜底:openjoox2 /track 详情的 lrc_content(同为 base64),旧接口失败时使用。
// 解码:base64 → UTF-8 → HTML 实体(&amp; 等)→ { lyric, tlyric: '' }
// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric }
import { httpFetch } from '../request.ts'
import { jooxHeaders, decodeJooxLyric, normalizeJooxID, firstNonEmpty } from './util.ts'
import { buildTrackUrl } from './musicInfo.ts'
export const LEGACY_LYRIC_URL = 'https://api.joox.com/web-fcgi-bin/web_lyric'
/** 解析 MusicJsonCallback(...) / 裸 JSON 包裹 */
export const parseLyricPayload = (body: any): any => {
if (body && typeof body === 'object') return body
let text = String(body ?? '')
const idx = text.indexOf('MusicJsonCallback(')
if (idx >= 0) {
text = text.slice(idx + 'MusicJsonCallback('.length)
text = text.replace(/\)\s*;?\s*$/, '')
}
try {
return JSON.parse(text)
} catch {
return null
}
}
/** 上游 GetLyrics 的纯逻辑部分:web_lyric 响应体 → 歌词文本 */
export const parseLegacyLyric = (statusCode: number, body: any): string => {
if (statusCode != 200) throw new Error(`joox 歌词获取失败(HTTP ${statusCode})`)
const payload = parseLyricPayload(body)
if (!payload) throw new Error('joox 歌词解析失败')
const raw = String(firstNonEmpty(payload.lyric, ''))
if (Number(payload.code ?? 0) !== 0 || !raw) throw new Error('joox 歌词不存在或为空')
return decodeJooxLyric(raw)
}
/** 上游 GetLyrics 通道(非取消句柄版,测试/内部用) */
export const getLegacyLyric = async(songId: string): Promise<string> => {
const url = `${LEGACY_LYRIC_URL}?musicid=${encodeURIComponent(songId)}&country=sg&lang=zh_cn`
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
return parseLegacyLyric(statusCode, body)
}
export const getLyric = (songInfo: any, ..._rest: any[]) => {
const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} }
let current: any = null
requestObj.cancelHttp = () => {
if (current?.cancelHttp) current.cancelHttp()
}
const songId = normalizeJooxID(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid ?? '')
requestObj.promise = (async() => {
if (!songId) throw new Error('joox 歌曲 id 无效')
// 1) 上游 web_lyric 通道
let legacyErr: any = null
try {
current = httpFetch(
`${LEGACY_LYRIC_URL}?musicid=${encodeURIComponent(songId)}&country=sg&lang=zh_cn`,
{ headers: jooxHeaders(), timeout: 20000 },
)
const { statusCode, body } = await current.promise
return { lyric: parseLegacyLyric(statusCode, body), tlyric: '' }
} catch (err) {
legacyErr = err
}
// 2) 兜底:openjoox2 详情 lrc_content(base64)
current = httpFetch(buildTrackUrl(songId), { headers: jooxHeaders(), timeout: 20000 })
const { statusCode, body } = await current.promise as any
const raw = String(firstNonEmpty(body?.lrc_content, ''))
if (statusCode != 200 || !raw) throw legacyErr ?? new Error('joox 歌词不存在或为空')
return { lyric: decodeJooxLyric(raw), tlyric: '' }
})()
return requestObj
}
export default { getLyric, getLegacyLyric, parseLyricPayload, parseLegacyLyric }
+83
View File
@@ -0,0 +1,83 @@
// JOOX 歌曲详情 / 取流通道(openjoox2 v1,web 播放器同款签名接口)
// 端点:GET https://cache.api.joox.com/openjoox2/v1/track/<id>
// ?country=hk&lang=zh_TW&lyric=1&fs=<0|1>&im=<0|1>[&uid=<wmid>][&usk=<session_key>]&secret=<md5>
// 签名:secret = md5('Jo0x@t3Nc3nT' + 'country=..&lang=..&lyric=1&fs=..&im=..[&uid=..][&usk=..]&id=..')
// 参数按插入序参与签名;id 用 '/' → '_' 替换后的值,且发布 URL 前会从 query 中删除 id。
// 该签名算法由官网 player-d JS bundle 逆向 + 实测比对(见 tmp/joox-test.mjs 断言)。
// 上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已于 2026-10 前 404,此文件为其替代。
// fs=1 请求完整曲目(游客仍可能拿到 preview/refrain),im=mobile 标记。
// 返回体含 play_url_list / refrain_url / lrc_content(base64) / qrc_content(base64) /
// has_hq / has_hifi / vip_flag / node_is_preview 等。
import { httpFetch } from '../request.ts'
import {
jooxHeaders,
jooxSign,
jooxLoginCookie,
cookieValue,
normalizeJooxID,
normalizeJooxLang,
} from './util.ts'
export const JOOX_OPENJOOX2 = 'https://cache.api.joox.com/openjoox2/v1'
interface TrackOptions {
country?: string
lang?: string
/** true 请求完整曲目(fs=1),false 预览(fs=0) */
fullSong?: boolean
isMobile?: boolean
}
/** 构造 openjoox2 歌曲详情 URL(含签名;登录态自动带 uid/usk) */
export const buildTrackUrl = (rawId: unknown, options: TrackOptions = {}): string => {
const id = String(normalizeJooxID(rawId)).replace(/\//g, '_')
const country = options.country || 'hk'
const lang = normalizeJooxLang(options.lang || 'zh_TW')
const params: Record<string, any> = {
country,
lang,
lyric: 1,
fs: options.fullSong ? 1 : 0,
im: options.isMobile ? 1 : 0,
}
// 仅绑定登录凭据后才带 uid/usk(游客态与官网播放器一致,不带这两项)
const loginCookie = jooxLoginCookie()
const uid = loginCookie ? cookieValue(loginCookie, 'wmid') : ''
const usk = loginCookie ? cookieValue(loginCookie, 'session_key') : ''
if (uid) params.uid = uid
if (usk) params.usk = usk
// 签名时包含 id(与 web 播放器一致),发布 query 时再删除
const secret = jooxSign({ ...params, id })
const query = Object.entries({ ...params, secret })
.map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`)
.join('&')
return `${JOOX_OPENJOOX2}/track/${id}?${query}`
}
/** 歌曲详情(openjoox2;上游 fetchSongInfo 的替代实现) */
export const getTrackDetail = async (rawId: unknown, options: TrackOptions = {}): Promise<any> => {
const id = normalizeJooxID(rawId)
if (!id) throw new Error('joox 歌曲 id 无效')
const url = buildTrackUrl(id.replace(/\//g, '_'), options)
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
if (statusCode != 200 || !body || typeof body !== 'object' || body.code) {
const msg = body?.msg || body?.message || `HTTP ${statusCode}`
throw new Error(`joox 歌曲详情获取失败:${msg}`)
}
return body
}
/** 从详情里挑选可播放地址:play_url_list 优先,refrain_url 兜底;并还原 &amp; 实体 */
export const pickTrackUrl = (detail: any): string => {
const clean = (value: any) => String(value ?? '').replace(/&amp;/g, '&')
const urls: any[] = Array.isArray(detail?.play_url_list) ? detail.play_url_list : []
for (const url of urls) {
const text = clean(url)
if (text.startsWith('http')) return text
}
const refrain = clean(detail?.refrain_url)
return refrain.startsWith('http') ? refrain : ''
}
export default { buildTrackUrl, getTrackDetail, pickTrackUrl }
+42
View File
@@ -0,0 +1,42 @@
// JOOX 搜索(上游 joox/song.go Search 移植)
// 端点:GET https://cache.api.joox.com/openjoox/v3/search?country=sg&lang=zh_cn&keyword=<kw>
// 返回 section_list(0 歌曲 / 2 歌手 / 1 专辑 / 3 歌单 / 7 用户),歌曲在 item_list[].song[].song_info。
// 该接口无分页参数(上游也未分页),page 参数仅为统一契约保留;allPage 恒为 1。
// 实测(2026-10-02):英文/中文关键词均 HTTP 200。
import { httpFetch } from '../request.ts'
import { jooxHeaders } from './util.ts'
import { jooxSongFromSearch } from './song.ts'
const SEARCH_URL = 'https://cache.api.joox.com/openjoox/v3/search'
export const search = 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: [], allPage: 0, total: 0, 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 ?? []) {
for (const entry of item?.song ?? []) {
const song = jooxSongFromSearch(entry?.song_info)
if (!song || seen.has(song.songmid)) continue
seen.add(song.songmid)
list.push(song)
}
}
}
return {
list: list.slice(0, limit),
allPage: 1,
total: list.length,
limit,
source: 'joox' as const,
}
}
export default { search }
+28
View File
@@ -0,0 +1,28 @@
// JOOX 链接解析(上游 joox/song.go Parse + album.go ParseAlbum + playlist.go ParsePlaylist)
// 歌曲:joox.com/<region>/single/<id> 或裸 ID(>10 字符且不含 '/')→ openjoox2 详情 → Song(含直链)
// 专辑:joox.com/.../album/<id>、h_activity_id=、albumid= → album.getAlbumDetail
// 歌单:joox.com/.../playlist/<id>、playlistid|playlist_id|id= → playlist.getPlaylistDetail
import { normalizeJooxID } from './util.ts'
import { getTrackDetail, pickTrackUrl } from './musicInfo.ts'
import { jooxSongFromDetail } from './song.ts'
import { getAlbumDetail, parseAlbum } from './album.ts'
import { getPlaylistDetail, parsePlaylist } from './playlist.ts'
/** 歌曲链接 → Song(上游 Parse 的正则与裸 ID 规则) */
export const parseSong = async(link: string) => {
const raw = String(link ?? '').trim()
const m = /joox\.com\/.*\/single\/([^/?#]+)/.exec(raw)
let songId = ''
if (m) {
songId = normalizeJooxID(m[1])
} else if (raw.length > 10 && !raw.includes('/')) {
songId = normalizeJooxID(raw)
} else {
throw new Error('invalid joox link')
}
const detail = await getTrackDetail(songId)
return jooxSongFromDetail(detail, pickTrackUrl(detail))
}
export default { parseSong, parseAlbum, parsePlaylist }
+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,
}
+73
View File
@@ -0,0 +1,73 @@
// JOOX 歌曲形状映射(统一 Song 形状,见 tmp/port-spec.md)
// 兼容来源:openjoox2 track/album/playlist 条目、openjoox v3 search 的 song_info、
// 以及 SSR 页面上的 track 条目(字段有 snake/camel 两套命名,上游 Go 也两套都兜底)。
import { formatPlayTime } from '../common/utils.ts'
import {
normalizeJooxID,
pickJooxImage,
jooxArtists,
firstNonEmpty,
firstNonZero,
} from './util.ts'
/** 详情/列表 track 条目 → 统一 Song 形状 */
export const jooxSongFromTrack = (
item: any,
fallbackAlbum = '',
fallbackCover = '',
fallbackAlbumId = '',
): any | null => {
const songmid = normalizeJooxID(item?.id)
if (!songmid) return null
const artists = Array.isArray(item?.artist_list) && item.artist_list.length
? item.artist_list
: item?.artistList
const albumId = normalizeJooxID(firstNonEmpty(item?.album_id, item?.albumId, fallbackAlbumId))
return {
songmid,
name: String(firstNonEmpty(item?.name, item?.title, 'Unknown')),
singer: String(firstNonEmpty(jooxArtists(artists), 'Unknown')),
albumId: String(albumId || ''),
albumName: String(firstNonEmpty(item?.album_name, item?.albumName, fallbackAlbum)),
interval: formatPlayTime(firstNonZero(item?.play_duration, item?.playDuration, item?.duration)),
img: String(firstNonEmpty(
pickJooxImage(item?.images),
item?.imgSrc,
item?.cover,
item?.image,
item?.pic,
fallbackCover,
)) || null,
lrc: null,
vip: Number(item?.vip_flag ?? 0) === 1,
types: [],
_types: {},
source: 'joox' as const,
}
}
/** openjoox v3 搜索歌曲条目(song_info)→ 统一 Song 形状 */
export const jooxSongFromSearch = (info: any): any | null => jooxSongFromTrack(info)
/** 详情接口返回的整曲信息(parse 用,含可播放地址)→ 统一 Song 形状 */
export const jooxSongFromDetail = (detail: any, playUrl = ''): any => {
const song = jooxSongFromTrack(detail) ?? {
songmid: normalizeJooxID(detail?.id),
name: String(detail?.name ?? ''),
singer: String(jooxArtists(detail?.artist_list)),
albumId: String(normalizeJooxID(detail?.album_id)),
albumName: String(detail?.album_name ?? ''),
interval: formatPlayTime(firstNonZero(detail?.play_duration)),
img: String(pickJooxImage(detail?.images)) || null,
lrc: null,
types: [],
_types: {},
source: 'joox' as const,
}
if (playUrl) song.url = playUrl
song.vip = Number(detail?.vip_flag ?? 0) === 1
return song
}
+181
View File
@@ -0,0 +1,181 @@
// JOOX 平台公共工具:请求头/签名/ID 归一化/链接构造/页面数据提取
// 背景(2026-10-02 实测):
// - JOOX 是东南亚服务,www.joox.com 会按来源 IP 重定向到 /intl;上游 Go 通过伪造
// X-Forwarded-For 为东南亚 IP(36.73.34.109)绕过,本移植保持一致。
// - 新版 web 播放器走 cache.api.joox.com/openjoox2/v1/*,必须带 secret 签名:
// secret = md5('Jo0x@t3Nc3nT' + 参数按插入序 k=v&... )(值经 encodeURIComponent),
// 密钥来自官网 JS bundle 常量 secretAPIKey(web 公开常量,非账号密钥)。
// - 旧接口 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(取流/歌曲详情改用 openjoox2),
// 但 web_lyric 仍可用(歌词仍走上游接口)。
// - 上游 New(cookie):cookie 用于游客态/登录态请求;登录 cookie 里的 wmid/session_key
// 在签名参数里作为 uid/usk 参与(可拿到完整曲目/更高档位)。
import crypto from 'node:crypto'
import { httpFetch } from '../request.ts'
import { getCredentials } from '../auth/store.ts'
export const JOOX_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36'
/** 上游 Go 的默认游客 Cookie(jaox.go 常量 Cookie) */
export const JOOX_DEFAULT_COOKIE = 'wmid=142420656; user_type=1; country=id; session_key=2a5d97d05dc8fe238150184eaf3519ad;'
/** 上游 Go 的 X-Forwarded-For(东南亚 IP,绕过地区重定向) */
export const JOOX_XFF = '36.73.34.109'
/** openjoox2 签名密钥(官网 web bundle 常量 secretAPIKey) */
export const JOOX_SECRET_KEY = 'Jo0x@t3Nc3nT'
export const md5Hex = (str: string): string => crypto.createHash('md5').update(str, 'utf8').digest('hex')
/**
* openjoox2 签名:md5(secretAPIKey + `k=encodeURIComponent(v)` 按插入序以 & 连接)
* 注意键序即对象插入序(服务端按同序校验),不要重排参数。
*/
export const jooxSign = (params: Record<string, any>): string =>
md5Hex(JOOX_SECRET_KEY + Object.keys(params).map(key => `${key}=${encodeURIComponent(String(params[key]))}`).join('&'))
/** 绑定到 sdk/auth/store.ts 的登录 cookie(未登录返回空串,不回退游客 cookie) */
export const jooxLoginCookie = (): string => {
const cred = getCredentials('joox') as { cookie?: string } | null
return cred?.cookie || ''
}
/** 请求用 cookie(登录态优先;未登录回退上游游客 cookie) */
export const jooxCookie = (): string => jooxLoginCookie() || JOOX_DEFAULT_COOKIE
/** 从 cookie 串里取一个字段 */
export const cookieValue = (cookie: string, name: string): string => {
const m = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(String(cookie ?? ''))
return m ? m[1].trim() : ''
}
/** 平台公共请求头(含地区伪装与 Cookie) */
export const jooxHeaders = (extra: Record<string, any> = {}): Record<string, any> => ({
'User-Agent': JOOX_UA,
Cookie: jooxCookie(),
'X-Forwarded-For': JOOX_XFF,
Referer: 'https://www.joox.com/',
Accept: 'application/json, text/plain, */*',
...extra,
})
/** 语言归一(对齐官网 51bm.c:zh_hk/zh_mo → zh_TW,zh_cn → zh_CN) */
export const normalizeJooxLang = (lang?: string): string => {
if (lang === 'zh_hk' || lang === 'zh_mo') return 'zh_TW'
if (lang === 'zh_cn') return 'zh_CN'
return lang || 'zh_TW'
}
/** ID 归一化(上游 normalizeJooxID):去空白 + 百分号解码(空格还原为 +) */
export const normalizeJooxID = (raw: unknown): string => {
let id = String(raw ?? '').trim()
if (!id) return ''
if (id.includes('%')) {
try {
id = decodeURIComponent(id)
} catch {
// 上游 PathUnescape/QueryUnescape 失败时保留原值
}
id = id.replace(/ /g, '+')
}
return id
}
export const jooxSongLink = (id: unknown): string => `https://www.joox.com/hk/single/${normalizeJooxID(id)}`
export const jooxAlbumLink = (id: unknown): string => `https://www.joox.com/hk/album/${normalizeJooxID(id)}`
export const jooxRegionalPlaylistLink = (region: string, id: unknown): string =>
`https://www.joox.com/${region}/playlist/${normalizeJooxID(id)}`
export const jooxPlaylistLink = (id: unknown): string => jooxRegionalPlaylistLink('id', id)
/** 歌单页候选(上游顺序:id → sg → hk,逐个尝试直到页面数据可用) */
export const jooxPlaylistLinks = (id: unknown): string[] =>
['id', 'sg', 'hk'].map(region => jooxRegionalPlaylistLink(region, id))
/** 封面挑选(上游 pickJooxImage:优先 300 → 1000 → 100,再回退首个非空) */
export const pickJooxImage = (images: any[]): string => {
const list = Array.isArray(images) ? images : []
for (const width of [300, 1000, 100]) {
for (const image of list) {
const url = String(image?.url ?? '').trim()
if (Number(image?.width) === width && url) return url
}
}
for (const image of list) {
const url = String(image?.url ?? '').trim()
if (url) return url
}
return ''
}
/** 歌手拼接(上游 joinJooxArtists,分隔符 ' / ') */
export const jooxArtists = (list: any[]): string =>
(Array.isArray(list) ? list : []).map(a => String(a?.name ?? '').trim()).filter(Boolean).join(' / ')
export const firstNonEmpty = (...values: any[]): any => {
for (const value of values) {
if (value != null && String(value).trim() !== '') return value
}
return ''
}
export const firstNonZero = (...values: any[]): number => {
for (const value of values) {
const n = Number(value)
if (Number.isFinite(n) && n > 0) return n
}
return 0
}
/** base64 文本解码(上游 decodeJooxBase64Text:容忍 URL-safe 与缺失填充) */
export const decodeJooxBase64Text = (value: unknown): string => {
const raw = String(value ?? '').trim()
if (!raw) return ''
const normalized = raw.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
try {
const text = Buffer.from(padded, 'base64').toString('utf8')
// 解码结果是二进制噪声时回退原值(对齐上游 stdout base64 校验语义)
return text.includes('\uFFFD') ? raw : text.trim()
} catch {
return raw
}
}
/** HTML 实体解码(歌词文本兜底) */
export const decodeHtmlEntities = (text: unknown): string =>
String(text ?? '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#0*39;/g, "'")
.replace(/&nbsp;/g, ' ')
/** 歌词解码:base64 → utf8 → HTML 实体(上游 base64 + 常见实体兜底) */
export const decodeJooxLyric = (value: unknown): string => decodeHtmlEntities(decodeJooxBase64Text(value))
/** 从 SSR 页面提取 __NEXT_DATA__ JSON */
export const extractJooxNextData = (html: unknown): any => {
const m = /<script[^>]*id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/.exec(String(html ?? ''))
if (!m) return null
try {
return JSON.parse(m[1])
} catch {
return null
}
}
/**
* 拉取页面并手动跟随 3xx(request.ts 不自动跟跳;Go 客户端会自动跟)
* 返回最终页 HTML(非字符串 body 视为空串)
*/
export const fetchJooxPage = async (url: string, maxRedirect = 3): Promise<{ statusCode: number, body: string, finalUrl: string }> => {
let current = url
for (let i = 0; i <= maxRedirect; i++) {
const res: any = await httpFetch(current, { headers: jooxHeaders(), timeout: 20000 }).promise
const location = res?.headers?.location
if (res.statusCode >= 300 && res.statusCode < 400 && location) {
current = new URL(String(location), current).href
continue
}
return { statusCode: res.statusCode, body: typeof res.body === 'string' ? res.body : '', finalUrl: current }
}
throw new Error('joox 页面重定向次数过多')
}