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

+77
View File
@@ -0,0 +1,77 @@
// Apple Music 专辑(上游 apple.go 的 SearchAlbum / ParseAlbum / GetAlbumSongs)
// 详情:amp-api /v1/catalog/<sf>/albums/<id>?extend=extendedAssetUrls
// → data[0].relationships.tracks.data[](>300 首时分页,next 由 fetchAllTrackResources 跟进)
// 链接:extractAppleID(link, 'album'),支持裸数字 id 与 /us/album/<slug>/<id> 链接。
import {
appleAmpGet,
appleAlbumToPlaylist,
appleSongFromCatalog,
extractAppleID,
fetchAllTrackResources,
getAppleConfig,
} from './util.ts'
export { searchAlbum } from './musicSearch.ts'
import { searchAlbum } from './musicSearch.ts'
/** 专辑详情(page/limit 在完整曲目上切片,与其它平台 getAlbumDetail 契约一致) */
export const getAlbumDetail = async(rawId: string, page = 1, limit = 50) => {
const albumId = extractAppleID(String(rawId ?? ''), 'album')
if (!albumId) throw new Error(`invalid apple music album id/link: ${rawId}`)
const cfg = getAppleConfig()
const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/albums/${albumId}`, { extend: 'extendedAssetUrls' })
const album = (body?.data ?? [])[0]
if (!album) throw new Error(`apple album not found: ${albumId}`)
const tracks = await fetchAllTrackResources(album?.relationships?.tracks)
const songs = tracks.map((track: any) => appleSongFromCatalog(track, albumId))
const p = Math.max(1, Number(page) || 1)
const l = Math.max(1, Number(limit) || 50)
const summary = appleAlbumToPlaylist(album)
// 上游 fetchPlaylistDetail 的做法:接口未给 trackCount 时以实际曲目数补齐
if (!summary.trackCount || songs.length > summary.trackCount) summary.trackCount = songs.length
return {
source: 'apple' as const,
info: {
id: albumId,
name: summary.name,
img: summary.cover,
desc: summary.description,
author: summary.creator,
link: summary.link,
trackCount: summary.trackCount,
releaseDate: summary.extra?.release_date || '',
recordLabel: summary.extra?.record_label || '',
},
list: songs.slice((p - 1) * l, (p - 1) * l + l),
page: p,
limit: l,
total: songs.length,
}
}
/** 上游 GetAlbumSongs:只返回曲目数组 */
export const getAlbumSongs = async(id: string): Promise<any[]> => {
const { list } = await getAlbumDetail(id, 1, 100000)
return list
}
/** 上游 ParseAlbum:专辑链接 → 详情(曲目全量,不分页) */
export const parseAlbumLink = async(link: string) => {
const detail = await getAlbumDetail(link, 1, 100000)
return {
source: 'apple' as const,
id: detail.info.id,
info: detail.info,
list: detail.list,
page: 1,
limit: Math.max(detail.total, 1),
total: detail.total,
}
}
export default { getAlbumDetail, getAlbumSongs, searchAlbum, parseAlbumLink }
+106
View File
@@ -0,0 +1,106 @@
// Apple Music 平台模块(上游 music-lib/apple/apple.go 的 Node/TS 移植,平台 id: apple)
// 能力:搜索 / 歌词(TTML,匿名 developer token 通常拿不到,见 lyric.ts)/ 封面 /
// 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+分类歌单+链接解析)/
// 链接解析(song/album/playlist)/ 取流(**仅 preview 试听片段**,见 sdk/apis/apple.ts)。
// 凭据:上游 New(cookie) → getCredentials('apple'),cookie 形如
// "media-user-token=xxx;storefront=cn;token=xxx",也支持 storefront/mediaUserToken/token 直填字段。
// 未实现(上游也没有):userPlaylist、榜单、热搜、歌手、评论、扫码登录。
import appleApi from '../apis/apple.ts'
import musicSearch from './musicSearch.ts'
import lyric from './lyric.ts'
import album from './album.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import {
appleArtworkURL,
appleSongFromCatalog,
appleWebURL,
extractAppleID,
fetchAppleSongResource,
getAppleConfig,
getAppleToken,
} from './util.ts'
import { getCredentials } from '../auth/store.ts'
const songIdOf = (songInfo: any): string => (
extractAppleID(String(songInfo?.songmid ?? songInfo?.id ?? ''), 'song')
)
const apple = {
/** 预热 developer token(失败不阻塞启动,路由调用时会重试) */
init() {
return getAppleToken().then(() => true).catch(() => false)
},
musicSearch,
getMusicUrl(songInfo, type) {
return appleApi.getMusicUrl(songInfo, type)
},
getLyric(songInfo, ...rest) {
return lyric.getLyric(songInfo, ...rest)
},
/** 封面:搜索结果自带 img;缺失时回源歌曲详情(best-effort) */
async getPic(songInfo) {
if (songInfo?.img) return String(songInfo.img)
const id = songIdOf(songInfo)
if (!id) return null
try {
const res = await fetchAppleSongResource(id)
return res ? (appleArtworkURL(res.attributes?.artwork, 600) || null) : null
} catch {
return null
}
},
/** 歌曲详情(best-effort;等价上游 Parse(id)) */
async getMusicInfo(songInfo) {
const id = songIdOf(songInfo)
if (!id) return null
const res = await fetchAppleSongResource(id, 'lyrics,albums')
return res ? appleSongFromCatalog(res) : null
},
album: {
getAlbumDetail: album.getAlbumDetail,
getAlbumSongs: album.getAlbumSongs,
searchAlbum: album.searchAlbum,
parseAlbumLink: album.parseAlbumLink,
},
playlist,
// 契约别名:Apple 的歌单分类即 genre curators,挂到 songList 键供 /song-list/apple 复用
songList: {
sortList: [{ name: '热门', id: '1526756058' }],
getTags() {
return playlist.getCategories()
},
getList(sortId: string, tagId: string, page = 1, limit = 20) {
const cate = String(tagId || sortId || '').trim()
return playlist.getCategoryPlaylists(cate, page, limit)
},
},
parse,
getMusicDetailPageUrl(songInfo) {
if (songInfo?.link) return String(songInfo.link)
const id = songIdOf(songInfo)
if (!id) return 'https://music.apple.com/'
return appleWebURL('song', id, getAppleConfig().storefront)
},
}
/** 登录状态(media-user-token 存在性判断;无在线校验接口) */
export const status = () => {
const cred = getCredentials('apple') as any
const cfg = getAppleConfig()
return (cfg.mediaUserToken || cred?.cookie)
? { logged: true, updatedAt: cred?.updatedAt }
: { logged: false }
}
export default apple
+50
View File
@@ -0,0 +1,50 @@
// Apple Music 歌词(上游 apple.go GetLyrics)
// 通道:amp-api /v1/catalog/<sf>/songs/<id>?include=lyrics&extend=extendedAssetUrls
// → data[0].relationships.lyrics.data[*].attributes.ttml
// 上游取到的是 TTML(Apple 逐字歌词格式,非 LRC),原样放入 lyric 字段返回;没有则忠实返回空串。
// 注意(2026-10 实测):匿名 developer token 请求时 Apple 不返回 lyrics relationship
// (即使 attributes.hasLyrics=true,歌单/搜索接口也一样),歌词接口需要 media-user-token(订阅态)才能拿到;
// 本实现保持上游行为:拿不到就返回 { lyric: '', tlyric: '' },不抛错。
// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric }
import { appleAmpGetTracked, extractAppleID, getAppleConfig } from './util.ts'
export const getLyric = (songInfo: any, ..._rest: any[]) => {
const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} }
const tracker = { current: null as any }
requestObj.cancelHttp = () => {
try {
tracker.current?.cancelHttp?.()
} catch {
// ignore
}
tracker.current = null
}
const songId = extractAppleID(String(songInfo?.songmid ?? songInfo?.id ?? ''), 'song')
requestObj.promise = (async() => {
if (!songId) throw new Error('apple 歌曲 id 无效')
const cfg = getAppleConfig()
const body: any = await appleAmpGetTracked(
`/v1/catalog/${cfg.storefront}/songs/${songId}`,
{ include: 'lyrics', extend: 'extendedAssetUrls' },
tracker,
)
const lyrics = body?.data?.[0]?.relationships?.lyrics?.data ?? []
for (const rel of lyrics) {
const text = String(rel?.attributes?.ttml ?? '')
if (text) return { lyric: text, tlyric: '' }
}
return { lyric: '', tlyric: '' }
})()
return requestObj
}
/** 非取消句柄版:直接返回歌词文本(内部/测试用) */
export const getLyricsText = async(songId: string): Promise<string> => {
const result = await getLyric({ songmid: songId }).promise
return result?.lyric || ''
}
export default { getLyric }
+74
View File
@@ -0,0 +1,74 @@
// Apple Music 搜索(上游 apple.go 的 Search / SearchAlbum / SearchPlaylist)
// 通道:amp-api /v1/catalog/<storefront>/search(Bearer developer token,见 util.ts)
// 单次请求上限 25 条,page/limit 用 offset 翻页;响应没有 total,
// 因此 total = 已翻过的条数 + 本页条数(下一页是否存在由 next 决定,allPage=页码+1)。
import {
appleAmpGet,
appleSongFromCatalog,
appleAlbumToPlaylist,
applePlaylistToPlaylist,
getAppleConfig,
} from './util.ts'
/** amp-api search 单页硬上限 */
export const SEARCH_PAGE_MAX = 25
const catalogSearch = async(keyword: string, types: string, page: number, limit: number) => {
const word = String(keyword ?? '').trim()
const p = Math.max(1, Number(page) || 1)
const l = Math.min(Math.max(1, Number(limit) || 20), 100)
if (!word) return { body: null, page: p, limit: l }
const cfg = getAppleConfig()
const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/search`, {
term: word,
types,
limit: Math.min(l, SEARCH_PAGE_MAX),
offset: (p - 1) * l,
})
return { body, page: p, limit: l }
}
/** 上游 Search:搜索歌曲 */
export const search = async(keyword: string, page = 1, limit = 20) => {
const { body, page: p, limit: l } = await catalogSearch(keyword, 'songs', page, limit)
const bucket = body?.results?.songs ?? {}
const list = (bucket.data ?? []).map((item: any) => appleSongFromCatalog(item))
const hasNext = Boolean(bucket.next)
return {
list,
allPage: hasNext ? p + 1 : p,
total: (p - 1) * l + list.length,
limit: l,
source: 'apple' as const,
}
}
/** 上游 SearchAlbum:搜索专辑(返回歌单摘要形状) */
export const searchAlbum = async(keyword: string, page = 1, limit = 20) => {
const { body, page: p, limit: l } = await catalogSearch(keyword, 'albums', page, limit)
const items = body?.results?.albums?.data ?? []
const list = items.map((item: any) => appleAlbumToPlaylist(item))
return {
list,
total: (p - 1) * l + list.length,
page: p,
limit: l,
source: 'apple' as const,
}
}
/** 上游 SearchPlaylist:搜索歌单 */
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const { body, page: p, limit: l } = await catalogSearch(keyword, 'playlists', page, limit)
const items = body?.results?.playlists?.data ?? []
const list = items.map((item: any) => applePlaylistToPlaylist(item))
return {
list,
total: (p - 1) * l + list.length,
page: p,
limit: l,
source: 'apple' as const,
}
}
export default { search, searchAlbum, searchPlaylist }
+22
View File
@@ -0,0 +1,22 @@
// Apple Music 链接解析(上游 apple.go Parse / ParseAlbum / ParsePlaylist)
// song: https://music.apple.com/us/album/<slug>/<albumId>?i=<songId>(?i= 优先)或裸数字 song id
// album: https://music.apple.com/us/album/<slug>/<id> 或裸数字 album id
// playlist: https://music.apple.com/us/playlist/<slug>/pl.xxxx 或裸 pl.* id
// 上游 Parse 会请求歌曲详情(include=lyrics,albums)并填入 preview URL。
import { appleSongFromCatalog, extractAppleID, fetchAppleSongResource } from './util.ts'
import { parseAlbumLink } from './album.ts'
import { parsePlaylistLink } from './playlist.ts'
/** 上游 Parse:单曲链接/裸 id → Song(含 previewUrl 试听直链) */
export const parseSong = async(link: string) => {
const songId = extractAppleID(String(link ?? ''), 'song')
if (!songId) throw new Error(`invalid apple music song link: ${link}`)
const res = await fetchAppleSongResource(songId, 'lyrics,albums')
if (!res) throw new Error(`apple song not found: ${songId}`)
return appleSongFromCatalog(res)
}
export const parseAlbum = async(link: string) => parseAlbumLink(link)
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
export default { parseSong, parseAlbum, parsePlaylist }
+165
View File
@@ -0,0 +1,165 @@
// Apple Music 歌单(上游 apple.go 的 SearchPlaylist / ParsePlaylist / GetPlaylistSongs /
// GetPlaylistCategories / GetCategoryPlaylists 与 applePlaylistToPlaylist 映射)
// 详情:amp-api /v1/catalog/<sf>/playlists/<id>?limit[tracks]=300&extend=extendedAssetUrls
// 曲目超过一页时沿 relationships.tracks.next 全量拉取(上游 fetchAllTrackResources,带 20 页上限防死循环)。
// 分类:上游使用写死的 Apple Music genre curator 列表(appleCuratorCategories),
// GetCategoryPlaylists 固定 storefront=cn + l=zh-Hans-CN(curator id 取自 cn 区)。
// 链接:extractAppleID(link, 'playlist'),支持 pl.* 裸 id 与 /us/playlist/<slug>/pl.* 链接。
import {
appleAmpGet,
applePlaylistToPlaylist,
appleSongFromCatalog,
extractAppleID,
fetchAllTrackResources,
getAppleConfig,
} from './util.ts'
export { searchPlaylist } from './musicSearch.ts'
import { searchPlaylist } from './musicSearch.ts'
/** 上游 appleCuratorCategories(从公开搜索落地页提取的 Apple Music genre curators) */
export const appleCuratorCategories: Array<{ id: string, name: string }> = [
{ id: '1526756058', name: '热门' },
{ id: '1479949880', name: 'C-Pop' },
{ id: '1019400042', name: '国语流行' },
{ id: '1019398918', name: '粤语流行' },
{ id: '1019399540', name: '国际流行' },
{ id: '1019399551', name: 'K-Pop' },
{ id: '1019399547', name: 'J-Pop' },
{ id: '989061415', name: '嘻哈/说唱' },
{ id: '1019400044', name: 'R&B' },
{ id: '1019400046', name: '摇滚' },
{ id: '1019397973', name: '另类音乐' },
{ id: '976439535', name: '舞曲' },
{ id: '976439536', name: '电子' },
{ id: '976439541', name: '独立音乐' },
{ id: '1019399549', name: '爵士乐' },
{ id: '1019398924', name: '古典音乐' },
{ id: '976439528', name: '蓝调' },
{ id: '976439534', name: '乡村音乐' },
{ id: '1531542847', name: '拉丁音乐' },
{ id: '988656348', name: '非洲音乐' },
{ id: '976439543', name: '金属乐' },
{ id: '976439550', name: '朋克乐' },
{ id: '1019400049', name: '不插电' },
{ id: '1231181168', name: '影视原声' },
{ id: '1441811365', name: 'DJ 混音精选' },
{ id: '1532467784', name: '瞩目之星' },
{ id: '1554938339', name: '年代之声' },
{ id: '1564180390', name: '空间音频' },
{ id: '989010186', name: '亲子' },
]
/** 上游 GetPlaylistCategories:静态返回 Apple Music genre curators */
export const getCategories = async() => ({
source: 'apple' as const,
list: appleCuratorCategories.map(item => ({ id: item.id, name: item.name, group: 'Apple Music 分类', source: 'apple' as const })),
})
/** 上游 GetCategoryPlaylists:按 curator 分类取歌单(cn storefront,单请求上限 25,循环补齐) */
export const getCategoryPlaylists = async(categoryID: string, page = 1, limit = 20) => {
const categoryId = String(categoryID ?? '').trim()
if (!categoryId) throw new Error('apple 分类 id 不能为空')
const p = Math.max(1, Number(page) || 1)
const l = Math.min(Math.max(1, Number(limit) || 20), 300)
const apiMax = 25
let offset = (p - 1) * l
const all: any[] = []
while (all.length < l) {
const batchSize = Math.min(apiMax, l - all.length)
let body: any
try {
body = await appleAmpGet(`/v1/catalog/cn/apple-curators/${categoryId}/playlists`, {
limit: batchSize,
offset,
l: 'zh-Hans-CN',
})
} catch (err) {
if (all.length > 0) break // 上游:已有数据时返回已取部分
throw err
}
const data = body?.data ?? []
all.push(...data.map((item: any) => applePlaylistToPlaylist(item)))
// 没有更多数据或没有下一页
if (data.length < batchSize || !body?.next) break
offset += data.length
}
return {
source: 'apple' as const,
list: all.slice(0, l),
page: p,
limit: l,
total: all.length,
}
}
/** 歌单详情(全量曲目后按 page/limit 切片) */
export const getPlaylistDetail = async(rawId: string, page = 1, limit = 100) => {
const playlistId = extractAppleID(String(rawId ?? ''), 'playlist')
if (!playlistId) throw new Error(`invalid apple music playlist id/link: ${rawId}`)
const cfg = getAppleConfig()
const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/playlists/${playlistId}`, {
'limit[tracks]': 300,
extend: 'extendedAssetUrls',
})
const item = (body?.data ?? [])[0]
if (!item) throw new Error(`apple playlist not found: ${playlistId}`)
const tracks = await fetchAllTrackResources(item?.relationships?.tracks)
const songs = tracks.map((track: any) => appleSongFromCatalog(track))
const p = Math.max(1, Number(page) || 1)
const l = Math.max(1, Number(limit) || 100)
const summary = applePlaylistToPlaylist(item)
if (!summary.trackCount || songs.length > summary.trackCount) summary.trackCount = songs.length
return {
source: 'apple' as const,
info: {
id: playlistId,
name: summary.name,
img: summary.cover,
desc: summary.description,
author: summary.creator,
link: summary.link,
trackCount: summary.trackCount,
},
list: songs.slice((p - 1) * l, (p - 1) * l + l),
page: p,
limit: l,
total: songs.length,
}
}
/** 上游 GetPlaylistSongs:只返回曲目数组 */
export const getPlaylistSongs = async(id: string): Promise<any[]> => {
const { list } = await getPlaylistDetail(id, 1, 100000)
return list
}
/** 上游 ParsePlaylist:歌单链接 → 详情(曲目全量,不分页) */
export const parsePlaylistLink = async(link: string) => {
const detail = await getPlaylistDetail(link, 1, 100000)
return {
source: 'apple' as const,
id: detail.info.id,
info: detail.info,
list: detail.list,
page: 1,
limit: Math.max(detail.total, 1),
total: detail.total,
}
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
getCategories,
getCategoryPlaylists,
parsePlaylistLink,
}
+452
View File
@@ -0,0 +1,452 @@
// Apple Music(music.apple.com / amp-api.music.apple.com)公共工具
// 上游 music-lib/apple/apple.go 的移植基础层:
// - New(cookie) → getCredentials('apple'):cookie 支持 "media-user-token=xxx;storefront=cn;token=xxx"
// - fetchAppleToken():从 music.apple.com 首页 → index-legacy JS 抓 developer token(amp-api 的 Bearer)
// 当前(2026-10)Apple 下发的 JWT 以 eyJ0eXAi({"typ":...})开头而非上游正则写的 eyJh,
// 故这里泛化为任意 JWT(eyJ…)并优先选择 issuer=AMPWebPlay 的 token;带回退与失效重取见 getAppleToken/invalidateAppleToken。
// - ampGet/ampHeaders:amp-api 请求(httpFetch 不自动跟 302,首页跳转用 fetchFollow 手动跟)
// - 模型转换:appleSongFromCatalog / appleAlbumToPlaylist / applePlaylistToPlaylist(字段映射同上游)
// - ID 提取:extractAppleID / extractAppleIDFromPath / isAppleRawID(apple_test.go 的断言全部逐条保留)
import { httpFetch } from '../request.ts'
import { formatPlayTime } from '../common/utils.ts'
import { getCredentials } from '../auth/store.ts'
export const APPLE_HOMEPAGE_URL = 'https://music.apple.com'
export const APPLE_AMP_API_URL = 'https://amp-api.music.apple.com'
export const DEFAULT_STOREFRONT = 'us'
export const APPLE_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
/** 上游 New(cookie) 的等价配置(cookie 字符串解析 + 凭据直填字段) */
export interface AppleRuntimeConfig {
storefront: string
/** media-user-token(订阅用户 token;歌词/完整曲目等受限资源需要) */
mediaUserToken: string
/** 显式提供的 developer token(上游 cookie 里的 token=),优先于网页抓取 */
token: string
}
/** 上游 parseCookieParts:按 ';' 拆分 cookie,'k=v' 去空白 */
export const parseCookieParts = (cookie: string): Record<string, string> => {
const result: Record<string, string> = {}
for (const part of String(cookie ?? '').split(';')) {
const trimmed = part.trim()
const idx = trimmed.indexOf('=')
if (idx > 0) {
const key = trimmed.slice(0, idx).trim()
const val = trimmed.slice(idx + 1).trim()
result[key] = val
}
}
return result
}
/** 读取 apple 平台运行时配置(对应上游 New(cookie)) */
export const getAppleConfig = (): AppleRuntimeConfig => {
const cred = (getCredentials('apple') as any) || {}
const parts = parseCookieParts(String(cred.cookie ?? ''))
const storefront = String(cred.storefront || parts.storefront || DEFAULT_STOREFRONT).trim()
return {
storefront: storefront || DEFAULT_STOREFRONT,
mediaUserToken: String(cred.mediaUserToken || parts['media-user-token'] || '').trim(),
token: String(cred.token || parts.token || '').trim(),
}
}
// ---------------- developer token ----------------
let cachedToken = ''
let cachedAt = 0
let pendingToken: Promise<string> | null = null
// 显式配置的 token 在 401/403 后不再优先使用,改为回源网页抓取
let bypassExplicitToken = false
// JWT 自身带 exp(通常数月),这里再加 12h 进程内缓存,避免频繁拉取数 MB 的 JS bundle
const TOKEN_CACHE_TTL = 12 * 60 * 60 * 1000
/** 解码 JWT payload(Apple token 为 ES256 JWT) */
export const decodeAppleTokenPayload = (token: string): any => {
try {
const part = String(token ?? '').split('.')[1] || ''
const json = Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
return JSON.parse(json)
} catch {
return {}
}
}
export const isAppleTokenExpired = (token: string): boolean => {
const exp = Number(decodeAppleTokenPayload(token)?.exp || 0)
return exp > 0 && exp * 1000 < Date.now() + 60 * 1000
}
/** 从 index JS 中提取所有 JWT 形态的候选 token(含引号/等号前缀的主匹配 + 无前缀回退) */
export const extractAppleTokens = (js: string): string[] => {
const pattern = '(eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,})'
const found: string[] = []
let m: RegExpExecArray | null
const primary = new RegExp(`(?:=["'])${pattern}`, 'g')
while ((m = primary.exec(String(js ?? '')))) found.push(m[1])
if (!found.length) {
const loose = new RegExp(pattern, 'g')
while ((m = loose.exec(String(js ?? '')))) found.push(m[1])
}
return [...new Set(found)]
}
/** 选择可用 token:优先 issuer=AMPWebPlay(amp-api 使用的那个),其次第一个未过期;全部过期视为抓取失败 */
export const pickAppleToken = (js: string): string => {
const tokens = extractAppleTokens(js)
if (!tokens.length) return ''
const alive = tokens.filter(t => !isAppleTokenExpired(t))
if (!alive.length) return ''
const preferred = alive.find(t => String(decodeAppleTokenPayload(t)?.iss || '') === 'AMPWebPlay')
return preferred || alive[0] || ''
}
/** 手动跟随 3xx(httpFetch 不自动跟跳;Apple 首页 → /cn → /cn/new 有两跳) */
export const fetchFollow = async(url: string, headers: Record<string, string> = {}, maxHops = 5): Promise<any> => {
let current = String(url)
for (let hop = 0; hop < maxHops; hop++) {
const res: any = await httpFetch(current, { headers, timeout: 20000 }).promise
const code = Number(res?.statusCode) || 0
const location = res?.headers?.location
if (code >= 300 && code < 400 && location) {
current = new URL(String(location), current).href
continue
}
return res
}
throw new Error('apple: too many redirects')
}
const responseText = (res: any): string => {
if (typeof res?.body === 'string') return res.body
return Buffer.isBuffer(res?.raw) ? res.raw.toString('utf8') : String(res?.body ?? '')
}
/** 上游 fetchAppleToken:首页 → index-legacy JS → bearer token */
export const fetchAppleToken = async(): Promise<string> => {
const home = await fetchFollow(APPLE_HOMEPAGE_URL, { 'User-Agent': APPLE_UA })
if (Number(home?.statusCode) !== 200) throw new Error(`apple: fetch homepage error: HTTP ${home?.statusCode}`)
const html = responseText(home)
// 上游:/(assets/index-legacy[~\-][^/"]+\.js);当前构建为 index-legacy~hash.js(回退主入口 index~hash.js)
let match = /\/(assets\/index-legacy[~-][^/"']+\.js)/.exec(html)
if (!match) match = /\/(assets\/index[~-][^/"']+\.js)/.exec(html)
if (!match) throw new Error('apple: index.js URI not found')
const jsRes = await fetchFollow(`${APPLE_HOMEPAGE_URL}/${match[1]}`, { 'User-Agent': APPLE_UA })
if (Number(jsRes?.statusCode) !== 200) throw new Error(`apple: fetch index.js error: HTTP ${jsRes?.statusCode}`)
const token = pickAppleToken(responseText(jsRes))
if (!token) throw new Error('apple: token not found in index.js')
return token
}
/**
* 获取 developer token(缓存 + JWT exp 校验 + 并发去重)
* force=true 或显式 token 失效(401/403 后)时回源重新抓取
*/
export const getAppleToken = async(force = false): Promise<string> => {
const cfg = getAppleConfig()
if (!force && !bypassExplicitToken && cfg.token) return cfg.token
if (!force && cachedToken && Date.now() - cachedAt < TOKEN_CACHE_TTL && !isAppleTokenExpired(cachedToken)) {
return cachedToken
}
if (!pendingToken) {
pendingToken = (async() => {
try {
const token = await fetchAppleToken()
cachedToken = token
cachedAt = Date.now()
return token
} finally {
pendingToken = null
}
})()
}
return pendingToken
}
/** 清除缓存 token(401/403 时调用,下次请求会回源重取) */
export const invalidateAppleToken = (): void => {
cachedToken = ''
cachedAt = 0
bypassExplicitToken = true
}
/** 仅测试/调试用:重置内存态 */
export const resetAppleTokenState = (): void => {
cachedToken = ''
cachedAt = 0
bypassExplicitToken = false
pendingToken = null
}
// ---------------- amp-api 请求 ----------------
export const appleAmpHeaders = (token: string): Record<string, string> => {
const cfg = getAppleConfig()
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
Origin: APPLE_HOMEPAGE_URL,
Referer: `${APPLE_HOMEPAGE_URL}/`,
'User-Agent': APPLE_UA,
Accept: 'application/json',
}
// 上游 ampHeaders:cookie 中的 media-user-token 作为 Cookie 携带
if (cfg.mediaUserToken) headers.Cookie = `media-user-token=${cfg.mediaUserToken}`
return headers
}
/** 拼接 amp-api URL:支持以 '/' 开头的路径与 next 返回的完整 URL */
export const buildAppleAmpUrl = (uri: string, params?: Record<string, any> | URLSearchParams | null): string => {
const raw = String(uri ?? '')
const base = /^https?:\/\//i.test(raw)
? raw
: raw.startsWith('/')
? APPLE_AMP_API_URL + raw
: `${APPLE_AMP_API_URL}/${raw}`
let query = ''
if (params instanceof URLSearchParams) {
query = params.toString()
} else if (params && typeof params === 'object') {
const search = new URLSearchParams()
for (const key of Object.keys(params)) {
const value = params[key]
if (value == null || value === '') continue
search.append(key, String(value))
}
query = search.toString()
}
if (!query) return base
return base + (base.includes('?') ? '&' : '?') + query
}
const ampErrorDetail = (res: any): string => {
const first = res?.body?.errors?.[0]
return String(first?.detail || first?.title || '')
}
/**
* amp-api GET(带 token 失效重取:401/403 时清缓存重试一次)
* tracker 用于把内层 httpFetch 请求对象暴露给调用方做 cancelHttp
*/
export const appleAmpGetTracked = async(
uri: string,
params: Record<string, any> | URLSearchParams | null | undefined,
tracker?: { current: any },
): Promise<any> => {
const url = buildAppleAmpUrl(uri, params)
const attempt = async(): Promise<any> => {
const token = await getAppleToken()
const request = httpFetch(url, { headers: appleAmpHeaders(token), timeout: 20000 })
if (tracker) tracker.current = request
return request.promise
}
let res: any = await attempt()
if (res?.statusCode === 401 || res?.statusCode === 403) {
invalidateAppleToken()
res = await attempt()
}
if (!(res?.statusCode >= 200 && res?.statusCode < 300)) {
const detail = ampErrorDetail(res)
throw new Error(`apple amp-api HTTP ${res?.statusCode}${detail ? `: ${detail}` : ''}`)
}
return res.body
}
export const appleAmpGet = async(uri: string, params?: Record<string, any> | null): Promise<any> => (
appleAmpGetTracked(uri, params, { current: null })
)
/** 拉取某资源 relationships.tracks 的全部分页(上游 fetchAllTrackResources;带页数上限防死循环) */
const MAX_TRACK_PAGES = 20
export const fetchAllTrackResources = async(tracks: any): Promise<any[]> => {
const all: any[] = [...(tracks?.data ?? [])]
let nextUri = String(tracks?.next ?? '').trim()
for (let page = 0; nextUri && page < MAX_TRACK_PAGES; page++) {
const body: any = await appleAmpGetTracked(nextUri, null, undefined)
all.push(...(body?.data ?? []))
nextUri = String(body?.next ?? '').trim()
}
return all
}
// ---------------- 模型转换(字段映射同上游) ----------------
/** 上游 appleArtwork.CoverURL(size):替换 {w}/{h} 占位 */
export const appleArtworkURL = (artwork: any, size = 600): string => {
const url = String(artwork?.url ?? '')
if (!url) return ''
return url.replace(/\{w\}/g, String(size)).replace(/\{h\}/g, String(size))
}
/** 上游 appleSongFromCatalog → 契约 Song 形状 */
export const appleSongFromCatalog = (res: any, defaultAlbumId = ''): any => {
const attr = res?.attributes ?? {}
const previewURL = Array.isArray(attr.previews) && attr.previews.length ? String(attr.previews[0]?.url ?? '') : ''
const durationSec = Math.round(Number(attr.durationInMillis ?? 0) / 1000)
return {
songmid: String(res?.id ?? ''),
name: attr.name || '',
singer: attr.artistName || '',
albumId: String(attr.albumId || defaultAlbumId || ''),
albumName: attr.albumName || '',
interval: formatPlayTime(durationSec),
img: appleArtworkURL(attr.artwork, 600) || null,
lrc: null,
types: [],
_types: {},
source: 'apple' as const,
link: attr.url || '', // music.apple.com 网页地址
previewUrl: previewURL, // 30-90 秒试听片段直链(Apple 不提供完整曲目明文流)
duration: durationSec,
isrc: attr.isrc || '',
}
}
/** 上游 appleAlbumToPlaylist → 契约歌单/专辑摘要 */
export const appleAlbumToPlaylist = (res: any): any => {
const attr = res?.attributes ?? {}
return {
id: String(res?.id ?? ''),
name: attr.name || '',
cover: appleArtworkURL(attr.artwork, 300),
trackCount: Number(attr.trackCount ?? 0) || 0,
creator: attr.artistName || '',
description: attr.editorialNotes?.short || '',
link: attr.url || '',
source: 'apple' as const,
extra: {
release_date: attr.releaseDate || '',
record_label: attr.recordLabel || '',
},
}
}
/** 上游 applePlaylistToPlaylist → 契约歌单摘要(description 四级回退同上游) */
export const applePlaylistToPlaylist = (res: any): any => {
const attr = res?.attributes ?? {}
const description = attr.description?.short
|| attr.description?.standard
|| attr.editorialNotes?.short
|| attr.editorialNotes?.standard
|| ''
return {
id: String(res?.id ?? ''),
name: attr.name || '',
cover: appleArtworkURL(attr.artwork, 300),
trackCount: Number(attr.trackCount ?? 0) || 0,
creator: attr.curatorName || '',
description,
link: attr.url || '',
source: 'apple' as const,
}
}
/** 网页地址(搜索结果 link 缺失时的兜底) */
export const appleWebURL = (kind: 'song' | 'album' | 'playlist', id: string, storefront = DEFAULT_STOREFRONT): string => {
const sf = String(storefront || DEFAULT_STOREFRONT)
return `${APPLE_HOMEPAGE_URL}/${sf}/${kind}/${id}`
}
// ---------------- ID 提取(上游 extractAppleID/isAppleRawID/extractAppleIDFromPath) ----------------
/** 上游 isAppleRawID:裸 id 判定(playlist 支持 pl.* / 字母数字,其余要求纯数字) */
export const isAppleRawID = (value: string, mediaType: string): boolean => {
if (/[/?#]/.test(value) || value.includes('://')) return false
if (mediaType === 'playlist') {
if (value.startsWith('pl.')) return /^[a-zA-Z0-9._-]+$/.test(value)
return /^[a-zA-Z0-9_-]+$/.test(value)
}
return /^\d+$/.test(value)
}
/** 上游 extractAppleIDFromPath:在 path 段中找 <type>/<name>/<id> 或 <type>/<id> */
export const extractAppleIDFromPath = (path: string, mediaType: string): string => {
const trimmed = String(path ?? '').replace(/^\/+|\/+$/g, '')
if (!trimmed) return ''
const segments = trimmed.split('/')
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]
if (segment !== mediaType && segment !== `${mediaType}s`) continue
if (i + 2 < segments.length) return segments[i + 2]
if (i + 1 < segments.length) return segments[i + 1]
}
return ''
}
/** 上游 extractAppleID:裸 id / 链接(含地区段与 ?i= 单曲参数)/ 正则兜底 */
export const extractAppleID = (link: string, mediaType: string): string => {
const raw = String(link ?? '').trim()
if (!raw) return ''
if (isAppleRawID(raw, mediaType)) return raw
// 先按 URL 解析(Go url.Parse 的宽松语义:无协议头时把 '?' 前当 path)
let path = ''
let query = ''
try {
const parsed = new URL(raw)
path = parsed.pathname || ''
query = parsed.search || ''
} catch {
const idx = raw.indexOf('?')
path = idx >= 0 ? raw.slice(0, idx) : raw
query = idx >= 0 ? raw.slice(idx) : ''
}
if (path) {
if (mediaType === 'song') {
const songID = new URLSearchParams(query).get('i')?.trim()
if (songID) return songID
}
const fromPath = extractAppleIDFromPath(path, mediaType)
if (fromPath) return fromPath
}
const patterns: RegExp[] = [
new RegExp(`${mediaType}/[^/]+/(\\d+)`),
new RegExp(`${mediaType}s?/[^/]+/[^/]+/(\\d+)`),
/\/(\d+)$/,
/[?&]i=(\d+)/,
]
if (mediaType === 'playlist') {
patterns.push(
/playlists?\/[^/?#]+\/([a-zA-Z0-9._-]+)/,
/playlists?\/([a-zA-Z0-9._-]+)(?:[?#]|$)/,
)
}
for (const pattern of patterns) {
const match = pattern.exec(raw)
if (match && match[1] != null) return match[1]
}
return ''
}
// ---------------- 共享请求 ----------------
/** 歌曲详情(上游 Parse/GetLyrics/GetDownloadURL 共用的 /v1/catalog/<sf>/songs/<id>) */
export const fetchAppleSongResource = async(songId: string, include = ''): Promise<any> => {
const cfg = getAppleConfig()
const params: Record<string, string> = { extend: 'extendedAssetUrls' }
if (include) params.include = include
const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/songs/${songId}`, params)
const data = body?.data ?? []
return data.length ? data[0] : null
}
export default {
parseCookieParts,
getAppleConfig,
fetchAppleToken,
getAppleToken,
invalidateAppleToken,
appleAmpGet,
appleAmpGetTracked,
appleSongFromCatalog,
appleAlbumToPlaylist,
applePlaylistToPlaylist,
extractAppleID,
isAppleRawID,
extractAppleIDFromPath,
}