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:
1 parent
47ce651f46
commit
33b96f92c4
54 files changed
+6161
-5
No files matched your search
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user