refactor(api)!: 平台 id 改两字母短码——qian→bd、joox→jx、5sing→5s、bili→bl
对外 id 统一为两字母短码,与既有的 kw/kg/tx/wy/qs/xm/me/qt/lr 对齐; migu 与 apple 保持原样(Apple Music 没有自然的短码)。 **破坏性变更**:id 出现在所有 URL、/sources 返回、OpenAPI 示例以及 data/auth.json 的凭据键里,这四个平台已存的登录态会失效,需重新登录。 - sdk/index.ts:id 与 supportQuality 映射键 - sdk/types.ts:Source 联合类型 - src/routes.ts 与 routes-login.ts:路由路径与 schema 描述 (/login/bili/qr → /login/bl/qr 等)、/login/status 的响应键 - test/smoke-test.ts、README.md、docs/API.md 同步 有意未改:sdk 目录与文件名仍用平台名(sdk/qian/、sdk/apis/joox.ts …), 规则定为「目录=平台名,id=对外短码」,映射只在 SUPPORTED_SOURCES 一处声明。 域名与正则(joox.com、5sing.kugou.com、/joox\.com\//)、jooxSign 等标识符、 错误文案里的中文平台名、以及 kg 榜单名(含「joox本地热歌榜」)全部保持原样—— 它们只是恰好含平台名,并不是 id。 验证:typecheck ✓、pnpm test 112/112 ✓;另起临时实例实证 /sources 与 /login/status 的新 id 与展示名,新旧 id 均无残留。
This commit is contained in:
1 parent
14f7295fd4
commit
3381e9b906
33 files changed
+155
-155
No files matched your search
+2
-2
@@ -1,12 +1,12 @@
|
||||
// 5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块
|
||||
// 上游 music-lib/fivesing 移植(平台 id 统一为 '5sing',上游 model.Source 为 "fivesing")。
|
||||
// 上游 music-lib/fivesing 移植(平台 id 统一为 '5s',上游 model.Source 为 "fivesing")。
|
||||
// 能力:搜索(原创 yc / 翻唱 fc / 伴奏 bz,统一种类字段 songType)/ 取流(明文直链,档位 sq→hq→lq)/
|
||||
// 歌词(newget.dynamicWords)/ 歌单(搜索、详情、链接解析)/ 单曲链接解析。
|
||||
// 不实现:专辑、封面(上游无 GetPic)、分类歌单/个人歌单(上游显式 not supported,保留抛错语义)、
|
||||
// 榜单/热搜/歌手/评论(上游均无)。
|
||||
// 取流入口在 sdk/apis/5sing.ts;songmid 约定 "<songId>|<songType>"(如 "932557|yc",与上游 ID 一致)。
|
||||
// 登录:上游 New(cookie) 的 cookie 会随请求发送(付费/受限内容可能受益);
|
||||
// 本移植从 sdk/auth/store.ts 的 getCredentials('5sing') 读取 cookie(无则匿名,匿名实测可搜索/取流/歌词/歌单)。
|
||||
// 本移植从 sdk/auth/store.ts 的 getCredentials('5s') 读取 cookie(无则匿名,匿名实测可搜索/取流/歌词/歌单)。
|
||||
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import lyric from './lyric.ts'
|
||||
|
||||
@@ -106,7 +106,7 @@ export const fetchSongInfo = async(songID: string, songType: string, register?:
|
||||
link: buildSongLink(songType, songID),
|
||||
songType,
|
||||
typeName: kindName(songType),
|
||||
source: '5sing' as const,
|
||||
source: '5s' as const,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { formatPlayTime } from '../common/utils.ts'
|
||||
import { SEARCH_API, fetch5singJson, removeEmTags, unescapeHtml, buildSongLink, estimateDuration, kindName } from './util.ts'
|
||||
|
||||
/** 上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5sing') */
|
||||
/** 上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5s') */
|
||||
export const mapSearchSong = (item: any) => {
|
||||
const songType = String(item?.typeEname ?? '')
|
||||
const duration = estimateDuration(Number(item?.songSize) || 0)
|
||||
@@ -29,7 +29,7 @@ export const mapSearchSong = (item: any) => {
|
||||
typeName: String(item?.typeName ?? kindName(songType)),
|
||||
size: Number(item?.songSize) || 0,
|
||||
link: buildSongLink(songType, item?.songId),
|
||||
source: '5sing' as const,
|
||||
source: '5s' as const,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
const word = String(keyword ?? '').trim()
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const pageSize = Math.max(1, Number(limit) || 20)
|
||||
const empty = { list: [] as any[], allPage: 0, total: 0, limit: pageSize, source: '5sing' as const }
|
||||
const empty = { list: [] as any[], allPage: 0, total: 0, limit: pageSize, source: '5s' as const }
|
||||
if (!word) return empty
|
||||
|
||||
const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=0`
|
||||
@@ -50,7 +50,7 @@ export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
const total = Number(pageInfo.totalCount) || list.length
|
||||
// 服务端每页 10 条(固定),allPage 直接用 pageInfo.totalPages
|
||||
const allPage = Number(pageInfo.totalPages) || (list.length >= pageSize ? pageNo + 1 : pageNo)
|
||||
return { list, allPage, total, limit: pageSize, source: '5sing' as const }
|
||||
return { list, allPage, total, limit: pageSize, source: '5s' as const }
|
||||
}
|
||||
|
||||
export default { search }
|
||||
@@ -32,7 +32,7 @@ const mapPlaylistSong = (songID: string, kind: string, name: string, artist: str
|
||||
songType: kind,
|
||||
typeName: kindName(kind),
|
||||
link: buildSongLink(kind, songID),
|
||||
source: '5sing' as const,
|
||||
source: '5s' as const,
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -103,7 +103,7 @@ const mapPlaylistSummary = (item: any) => {
|
||||
// 上游怪癖:content 为字符串 "0" 时视为空简介
|
||||
description: desc === '0' ? '' : desc,
|
||||
link: buildPlaylistLink(userId, id),
|
||||
source: '5sing' as const,
|
||||
source: '5s' as const,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
const word = String(keyword ?? '').trim()
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const pageSize = Math.max(1, Number(limit) || 20)
|
||||
const empty = { list: [] as any[], total: 0, page: pageNo, limit: pageSize, source: '5sing' as const }
|
||||
const empty = { list: [] as any[], total: 0, page: pageNo, limit: pageSize, source: '5s' as const }
|
||||
if (!word) return empty
|
||||
|
||||
const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=1`
|
||||
@@ -132,7 +132,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
}
|
||||
|
||||
const total = Number(body?.pageInfo?.totalCount) || list.length
|
||||
return { list, total, page: pageNo, limit: pageSize, source: '5sing' as const }
|
||||
return { list, total, page: pageNo, limit: pageSize, source: '5s' as const }
|
||||
}
|
||||
|
||||
/** 元数据 + 全量曲目(上游 fetchPlaylistDetail 核心;HTML 不分页,一次拿全) */
|
||||
@@ -165,7 +165,7 @@ export const fetchPlaylistAll = async(playlistIdRaw: string) => {
|
||||
description: String(data?.C ?? ''),
|
||||
link: buildPlaylistLink(userId, playlistId),
|
||||
userId,
|
||||
source: '5sing' as const,
|
||||
source: '5s' as const,
|
||||
}
|
||||
|
||||
// 2) 歌单页 HTML(数字 userId 会 302 → 个性域名页,fetch5sing 已跟跳)
|
||||
@@ -191,7 +191,7 @@ export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
|
||||
const list = songs.slice(start, start + pageSize)
|
||||
const total = songs.length
|
||||
return {
|
||||
source: '5sing' as const,
|
||||
source: '5s' as const,
|
||||
info: {
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
@@ -222,7 +222,7 @@ export const parsePlaylistLink = async(link: string) => {
|
||||
if (!parsed) throw new Error('invalid 5sing playlist link')
|
||||
const { playlist, songs } = await fetchPlaylistAll(parsed.playlistId)
|
||||
return {
|
||||
source: '5sing' as const,
|
||||
source: '5s' as const,
|
||||
info: {
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
|
||||
+6
-6
@@ -1,8 +1,8 @@
|
||||
// 5sing 公共工具(上游 fivesing/fivesing.go 的 getFirstValid / removeEmTags 等移植)
|
||||
// 平台 id 统一为 '5sing'(上游包名 fivesing,Go 源码 model.Source 用 "fivesing";
|
||||
// 本仓库契约要求 id 为 5sing,故所有对外 source 字段均为 '5sing'。
|
||||
// 平台 id 统一为 '5s'(上游包名 fivesing,Go 源码 model.Source 用 "fivesing";
|
||||
// 本仓库契约要求 id 为 5s,故所有对外 source 字段均为 '5s'。
|
||||
// 上游 New(cookie) 的 cookie 实际参与请求(搜索/getSongUrl/newget/歌单页),
|
||||
// 这里按约定从 sdk/auth/store.ts 的 getCredentials('5sing') 读取(无则匿名)。
|
||||
// 这里按约定从 sdk/auth/store.ts 的 getCredentials('5s') 读取(无则匿名)。
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
@@ -65,17 +65,17 @@ export const unescapeHtml = (s: any): string =>
|
||||
})
|
||||
|
||||
/** 上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用) */
|
||||
export const get5singCookie = (): string => String((getCredentials('5sing') as { cookie?: string } | null)?.cookie ?? '')
|
||||
export const get5singCookie = (): string => String((getCredentials('5s') as { cookie?: string } | null)?.cookie ?? '')
|
||||
|
||||
/** 登录态(凭据存在性判断,与 me 平台 status 对齐) */
|
||||
export const getStatus = () => {
|
||||
const cred = getCredentials('5sing') as { cookie?: string, updatedAt?: string } | null
|
||||
const cred = getCredentials('5s') as { cookie?: string, updatedAt?: string } | null
|
||||
return cred?.cookie ? { logged: true, updatedAt: cred.updatedAt } : { logged: false }
|
||||
}
|
||||
|
||||
/** 是否显式声明了非本平台的 source(上游 GetLyrics/GetDownloadURL 的 source mismatch 检查) */
|
||||
export const isForeignSource = (songInfo: any): boolean =>
|
||||
Boolean(songInfo?.source) && songInfo.source !== '5sing'
|
||||
Boolean(songInfo?.source) && songInfo.source !== '5s'
|
||||
|
||||
/**
|
||||
* 带 Cookie/UA 的请求(上游 utils.Get + http.Client 自动跟跳的等价实现)
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ const followRedirects = async(url: string, cookie: string, tracker?: { current:
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
* 统一取流接口:apis('bl').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
* promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位)
|
||||
* songInfo.songmid 约定 `<bvid>|<cid>`;仅给 bvid 时回源 view 取首P cid。
|
||||
*/
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
// wmid/session_key 会作为 uid/usk 参与签名(/login/joox 绑定后 VIP 曲目可取完整流)。
|
||||
// 实测:游客态返回 128k MP3(免费完整曲目)或 VIP 曲目的 preview/refrain 片段;
|
||||
// 未登录请求 VIP 曲目时 play_url_list 为试听片段——与上游行为一致(上游同样只能拿游客流)。
|
||||
// 统一接口约定:apis('joox').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// 统一接口约定:apis('jx').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// Bilibili(B站)平台模块 —— 上游 music-lib/bilibili 的移植(平台 id: bili)
|
||||
// Bilibili(B站)平台模块 —— 上游 music-lib/bilibili 的移植(平台 id: bl)
|
||||
// 能力:搜索 / 取流(DASH,VIP 可出 FLAC/Hi-Res)/ 歌单(合集 ugc_season + 多分P稿件)/
|
||||
// 链接解析(BV/av、分P)/ 封面 / 扫码登录(sdk/bili/login.ts,凭据落 sdk/auth/store.ts)
|
||||
// 不实现:专辑、歌词(上游即空实现)、榜单、热搜、歌手、评论、个人歌单、歌单分类(上游均未实现)。
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@
|
||||
// 0 成功 / 86038 过期 / 86090 已扫码待确认 / 86101 未扫码
|
||||
// 成功时登录态 Cookie 在 poll 响应的 Set-Cookie 与 data.url 的 crossDomain 查询串里(两处都收集)。
|
||||
// 凭据落盘到 sdk/auth/store.ts 的 `bili` 记录(cookie / refreshToken / 账号资料),与 kg/wy 一致;
|
||||
// 上游 New(cookie) 的实例化语义由 getCredentials('bili') 承担(见 util.ts getCookie)。
|
||||
// 上游 New(cookie) 的实例化语义由 getCredentials('bl') 承担(见 util.ts getCookie)。
|
||||
// 状态码沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功。
|
||||
import QRCode from 'qrcode'
|
||||
import { httpFetch } from '../common/request.ts'
|
||||
@@ -125,7 +125,7 @@ export const checkQRLogin = async(key: string) => {
|
||||
const cookie = cookieString(jar)
|
||||
if (!cookie) throw new Error('B站登录成功但未获取到 Cookie,请重试')
|
||||
const profile = await fetchProfile(cookie)
|
||||
setCredentials('bili', {
|
||||
setCredentials('bl', {
|
||||
cookie,
|
||||
refreshToken: String(data.refresh_token || ''),
|
||||
mid: profile.mid,
|
||||
@@ -148,7 +148,7 @@ export const checkQRLogin = async(key: string) => {
|
||||
|
||||
/** 当前登录态(凭据存在性 + 落盘时保存的账号资料) */
|
||||
export const status = () => {
|
||||
const cred = getCredentials('bili') as any
|
||||
const cred = getCredentials('bl') as any
|
||||
return cred?.cookie
|
||||
? {
|
||||
logged: true,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
// Bilibili 歌词(上游 bilibili/lyric.go 即空实现:GetLyrics 对 bilibili 源恒返回空字符串)
|
||||
// B 站视频没有公开歌词接口(cc_subtitle 需登录且绝大多数视频没有),上游未实现,此处保持一致:
|
||||
// 返回统一结构 { lyric: '', tlyric: '' },让 /lyric/bili 有响应而不是 404。
|
||||
// 返回统一结构 { lyric: '', tlyric: '' },让 /lyric/bl 有响应而不是 404。
|
||||
export const getLyric = (_songInfo?: any, ..._rest: any[]) => {
|
||||
const requestObj = {
|
||||
promise: Promise.resolve({ lyric: '', tlyric: '' }),
|
||||
|
||||
@@ -50,7 +50,7 @@ export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
allPage: numPages || p + (list.length >= l ? 1 : 0),
|
||||
total,
|
||||
limit: l,
|
||||
source: 'bili' as const,
|
||||
source: 'bl' as const,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
creator: view?.owner?.name || item.author || '',
|
||||
description: season.intro || '',
|
||||
link: `https://www.bilibili.com/video/${item.bvid}`,
|
||||
source: 'bili' as const,
|
||||
source: 'bl' as const,
|
||||
}
|
||||
}
|
||||
const pages: any[] = view?.pages ?? []
|
||||
@@ -73,14 +73,14 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
creator: view?.owner?.name || item.author || '',
|
||||
description: '',
|
||||
link: `https://www.bilibili.com/video/${item.bvid}`,
|
||||
source: 'bili' as const,
|
||||
source: 'bl' as const,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const list = result.filter(Boolean)
|
||||
return { list, page: p, limit: l, total: list.length, source: 'bili' as const }
|
||||
return { list, page: p, limit: l, total: list.length, source: 'bl' as const }
|
||||
}
|
||||
|
||||
/** 由 bvid 构造歌单详情(上游 GetPlaylistSongs 的 bvid 分支 + ParsePlaylist 的元数据) */
|
||||
@@ -167,7 +167,7 @@ const pageSlice = (detail: { info: any, songs: any[] }, page: any, limit: any) =
|
||||
const p = Math.max(1, Number(page) || 1)
|
||||
const l = Math.min(Math.max(1, Number(limit) || 100), 10000)
|
||||
return {
|
||||
source: 'bili' as const,
|
||||
source: 'bl' as const,
|
||||
info: detail.info,
|
||||
list: detail.songs.slice((p - 1) * l, p * l),
|
||||
page: p,
|
||||
@@ -216,7 +216,7 @@ const parsePlaylistView = async(bvid: string, view: any) => {
|
||||
let trackCount = countSeasonEpisodes(season.sections)
|
||||
if (!trackCount) trackCount = detail.songs.length
|
||||
return {
|
||||
source: 'bili' as const,
|
||||
source: 'bl' as const,
|
||||
id,
|
||||
info: { ...detail.info, trackCount, playCount: Number(season?.stat?.view) || undefined },
|
||||
list: detail.songs,
|
||||
@@ -227,7 +227,7 @@ const parsePlaylistView = async(bvid: string, view: any) => {
|
||||
}
|
||||
const detail = await detailFromBvid(bvid)
|
||||
return {
|
||||
source: 'bili' as const,
|
||||
source: 'bl' as const,
|
||||
id: bvidKey(bvid),
|
||||
info: { ...detail.info, trackCount: detail.songs.length },
|
||||
list: detail.songs,
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ export const biliHeaders = (cookie = ''): Record<string, string> => ({
|
||||
})
|
||||
|
||||
/** 已保存的登录 Cookie(未登录返回空串) */
|
||||
export const getLoginCookie = (): string => String((getCredentials('bili') as any)?.cookie || '')
|
||||
export const getLoginCookie = (): string => String((getCredentials('bl') as any)?.cookie || '')
|
||||
|
||||
const cookieMapFromHeaders = (headers: any): Record<string, string> => {
|
||||
const raw = headers?.['set-cookie']
|
||||
@@ -147,7 +147,7 @@ export const makeSong = ({ bvid, cid, name, artist, album, duration, cover, page
|
||||
lrc: null,
|
||||
types: [] as any[],
|
||||
_types: {} as Record<string, any>,
|
||||
source: 'bili' as const,
|
||||
source: 'bl' as const,
|
||||
bvid,
|
||||
cid: String(cid ?? ''),
|
||||
page,
|
||||
|
||||
+8
-8
@@ -54,10 +54,10 @@ const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModul
|
||||
{ id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true },
|
||||
{ id: 'lr', name: '懒人听书', module: lr, noAggregate: true },
|
||||
{ id: 'migu', name: '咪咕音乐', module: migu },
|
||||
{ id: 'qian', name: '千千音乐', module: qian },
|
||||
{ id: 'joox', name: 'JOOX', module: joox },
|
||||
{ id: 'bili', name: '哔哩哔哩', module: bili },
|
||||
{ id: '5sing', name: '5sing', module: fivesing },
|
||||
{ id: 'bd', name: '千千音乐', module: qian },
|
||||
{ id: 'jx', name: 'JOOX', module: joox },
|
||||
{ id: 'bl', name: '哔哩哔哩', module: bili },
|
||||
{ id: '5s', name: '5sing', module: fivesing },
|
||||
// Apple Music 仅提供 preview 试听片段(完整曲为 DRM),不参与跨平台精确匹配
|
||||
{ id: 'apple', name: 'Apple Music', module: apple, noAggregate: true },
|
||||
]
|
||||
@@ -82,10 +82,10 @@ export const supportQuality: Record<string, string[]> = {
|
||||
qt: qtQualitys,
|
||||
lr: lrQualitys,
|
||||
migu: miguQualitys,
|
||||
qian: qianQualitys,
|
||||
joox: jooxQualitys,
|
||||
bili: biliQualitys,
|
||||
'5sing': fivesingQualitys,
|
||||
bd: qianQualitys,
|
||||
jx: jooxQualitys,
|
||||
bl: biliQualitys,
|
||||
'5s': fivesingQualitys,
|
||||
apple: appleQualitys,
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -40,7 +40,7 @@ const fetchAlbumTracks = async(albumId: string, page: number, limit: number) =>
|
||||
|
||||
const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, list.length)
|
||||
return {
|
||||
source: 'joox' as const,
|
||||
source: 'jx' as const,
|
||||
info: {
|
||||
name: String(firstNonEmpty(body?.name, albumId)),
|
||||
img: pickJooxImage(body?.images) || null,
|
||||
@@ -95,7 +95,7 @@ export const getAlbumDetailFromPage = async(rawId: string, page = 1, limit = 50)
|
||||
|
||||
const total = firstNonZero(albumData?.trackList?.total_count, albumData?.trackList?.list_count, items.length)
|
||||
return {
|
||||
source: 'joox' as const,
|
||||
source: 'jx' as const,
|
||||
info: {
|
||||
name: String(firstNonEmpty(albumData?.title, albumId)),
|
||||
img: cover || null,
|
||||
@@ -120,7 +120,7 @@ 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 }
|
||||
return { list: [], total: 0, page, limit, source: 'jx' as const }
|
||||
}
|
||||
|
||||
const list: any[] = []
|
||||
@@ -142,12 +142,12 @@ export const searchAlbum = async(keyword: string, page = 1, limit = 20) => {
|
||||
publishDate: String(album.publish_date ?? '').trim(),
|
||||
trackCount: 0,
|
||||
link: jooxAlbumLink(id),
|
||||
source: 'joox' as const,
|
||||
source: 'jx' as const,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { list: list.slice(0, limit), total: list.length, page, limit, source: 'joox' as const }
|
||||
return { list: list.slice(0, limit), total: list.length, page, limit, source: 'jx' as const }
|
||||
}
|
||||
|
||||
/** 专辑链接解析(上游 ParseAlbum) */
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@
|
||||
// - 取流/歌曲详情/专辑曲目/歌单曲目: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 读取,
|
||||
// 上游 New(cookie):默认游客 Cookie 来自 joox.go 常量;/login/jx 绑定后从 sdk/auth/store.ts 读取,
|
||||
// 登录 cookie 的 wmid/session_key 会作为 uid/usk 参与 openjoox2 签名(VIP 完整曲目依赖它)。
|
||||
// userPlaylist:上游 joox/user_playlist.go 返回 ErrUserPlaylistsUnsupported,未实现(不造)。
|
||||
import jooxApi from '../apis/joox.ts'
|
||||
@@ -35,7 +35,7 @@ const joox = {
|
||||
album,
|
||||
playlist,
|
||||
// 契约别名:JOOX 的「分类/列表」能力即 playlist.go 的歌单分类,没有独立的 songList 接口,
|
||||
// 这里把同一套分类/分类歌单挂到 songList 键,便于 /song-list/joox 路由复用。
|
||||
// 这里把同一套分类/分类歌单挂到 songList 键,便于 /song-list/jx 路由复用。
|
||||
songList: {
|
||||
sortList: [{ name: '全部', id: '' }],
|
||||
getTags() {
|
||||
@@ -53,7 +53,7 @@ const joox = {
|
||||
|
||||
/** 登录状态(凭据存在性判断;无在线校验接口) */
|
||||
export const status = () => {
|
||||
const cred = getCredentials('joox') as { cookie?: string, updatedAt?: string } | null
|
||||
const cred = getCredentials('jx') as { cookie?: string, updatedAt?: string } | null
|
||||
return cred?.cookie
|
||||
? { logged: true, updatedAt: cred.updatedAt }
|
||||
: { logged: false }
|
||||
|
||||
@@ -14,7 +14,7 @@ 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 }
|
||||
return { list: [], allPage: 0, total: 0, limit, source: 'jx' as const }
|
||||
}
|
||||
|
||||
const list: any[] = []
|
||||
@@ -35,7 +35,7 @@ export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
allPage: 1,
|
||||
total: list.length,
|
||||
limit,
|
||||
source: 'joox' as const,
|
||||
source: 'jx' as const,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ const mapPlaylistSummary = (item: any) => {
|
||||
creator: '',
|
||||
description: '',
|
||||
link: jooxPlaylistLink(id),
|
||||
source: 'joox' as const,
|
||||
source: 'jx' as const,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ const fetchPlaylistTracks = async(playlistId: string, page: number, limit: numbe
|
||||
|
||||
const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, body?.track_count, list.length)
|
||||
return {
|
||||
source: 'joox' as const,
|
||||
source: 'jx' as const,
|
||||
info: {
|
||||
name: String(firstNonEmpty(body?.name, playlistId)),
|
||||
img: pickJooxImage(body?.images) || null,
|
||||
@@ -136,7 +136,7 @@ export const getPlaylistDetailFromPage = async(rawId: string) => {
|
||||
.filter(Boolean)
|
||||
if (!list.length) throw new Error('joox 歌单无歌曲')
|
||||
return {
|
||||
source: 'joox' as const,
|
||||
source: 'jx' as const,
|
||||
info: {
|
||||
name: String(parsed.name || pageId),
|
||||
img: cover || null,
|
||||
@@ -185,7 +185,7 @@ 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 }
|
||||
return { list: [], total: 0, page, limit, source: 'jx' as const }
|
||||
}
|
||||
|
||||
const list: any[] = []
|
||||
@@ -200,7 +200,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
}
|
||||
}
|
||||
|
||||
return { list: list.slice(0, limit), total: list.length, page, limit, source: 'joox' as const }
|
||||
return { list: list.slice(0, limit), total: list.length, page, limit, source: 'jx' as const }
|
||||
}
|
||||
|
||||
/** 歌单分类页原始数据(上游 fetchJooxPlaylistCategoriesPage) */
|
||||
@@ -230,7 +230,7 @@ export const getCategories = async() => {
|
||||
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 }
|
||||
return { source: 'jx' as const, list }
|
||||
}
|
||||
|
||||
/** 分类下歌单(上游 GetCategoryPlaylists) */
|
||||
@@ -282,13 +282,13 @@ export const getCategoryPlaylists = async(categoryId = '', page = 1, limit = 20)
|
||||
link: jooxPlaylistLink(id),
|
||||
categoryId: cid,
|
||||
categoryName,
|
||||
source: 'joox' as const,
|
||||
source: 'jx' as const,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (!list.length) throw new Error('joox 分类下无歌单')
|
||||
|
||||
return { list, total: items.length, page, limit, source: 'joox' as const }
|
||||
return { list, total: items.length, page, limit, source: 'jx' as const }
|
||||
}
|
||||
|
||||
/** 歌单链接解析(上游 ParsePlaylist) */
|
||||
|
||||
+2
-2
@@ -45,7 +45,7 @@ export const jooxSongFromTrack = (
|
||||
vip: Number(item?.vip_flag ?? 0) === 1,
|
||||
types: [],
|
||||
_types: {},
|
||||
source: 'joox' as const,
|
||||
source: 'jx' as const,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export const jooxSongFromDetail = (detail: any, playUrl = ''): any => {
|
||||
lrc: null,
|
||||
types: [],
|
||||
_types: {},
|
||||
source: 'joox' as const,
|
||||
source: 'jx' as const,
|
||||
}
|
||||
if (playUrl) song.url = playUrl
|
||||
song.vip = Number(detail?.vip_flag ?? 0) === 1
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ export const jooxSign = (params: Record<string, any>): string =>
|
||||
|
||||
/** 绑定到 sdk/auth/store.ts 的登录 cookie(未登录返回空串,不回退游客 cookie) */
|
||||
export const jooxLoginCookie = (): string => {
|
||||
const cred = getCredentials('joox') as { cookie?: string } | null
|
||||
const cred = getCredentials('jx') as { cookie?: string } | null
|
||||
return cred?.cookie || ''
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -29,7 +29,7 @@ const mapAlbumSummary = (item: QianAlbumSearchItem) => {
|
||||
genre: String(item?.genre ?? '').trim(),
|
||||
lang: String(item?.lang ?? '').trim(),
|
||||
link: id ? albumLink(id) : '',
|
||||
source: 'qian' as const,
|
||||
source: 'bd' as const,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export const searchAlbumItems = async(keyword: string, page = 1, limit = 10): Pr
|
||||
/** 搜索专辑(返回歌单/专辑统一摘要形状) */
|
||||
export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
|
||||
const word = String(keyword ?? '').trim()
|
||||
const empty = { source: 'qian' as const, list: [] as any[], total: 0, page, limit }
|
||||
const empty = { source: 'bd' as const, list: [] as any[], total: 0, page, limit }
|
||||
if (!word) return empty
|
||||
|
||||
const items = await searchAlbumItems(word, page, limit)
|
||||
@@ -82,7 +82,7 @@ export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
|
||||
.map(mapAlbumSummary)
|
||||
.filter((album) => album.id)
|
||||
.slice(0, limit)
|
||||
return { source: 'qian' as const, list, total: items.length, page, limit }
|
||||
return { source: 'bd' as const, list, total: items.length, page, limit }
|
||||
}
|
||||
|
||||
/** 旧数字专辑 id → PSID(P 码);已是 P 码则原样返回 */
|
||||
@@ -146,7 +146,7 @@ export const getAlbumDetail = async(id: string, page = 1, limit = 30) => {
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'qian' as const,
|
||||
source: 'bd' as const,
|
||||
info: albumInfo,
|
||||
list: paged,
|
||||
page,
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
// - VIP/版权曲目 tracklink 无 path(需登录态会员 cookie),搜索保留但标记 payPlay;
|
||||
// - 歌单关键词搜索(/v1/search?type=6)服务端已下线(errno 23001),searchPlaylist 返回空列表;
|
||||
// - 上游 GetUserPlaylists 明确 Unsupported,本移植不提供 userPlaylist。
|
||||
// 凭据:上游 New(cookie) 的 cookie 从 sdk/auth/store.ts 的 getCredentials('qian') 读取(可选)。
|
||||
// 凭据:上游 New(cookie) 的 cookie 从 sdk/auth/store.ts 的 getCredentials('bd') 读取(可选)。
|
||||
import qianApi from '../apis/qian.ts'
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import lyric from './lyric.ts'
|
||||
|
||||
@@ -6,7 +6,7 @@ import { APP_ID, apiGet, apiOk, mapTrack } from './util.ts'
|
||||
|
||||
export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
const word = String(keyword ?? '').trim()
|
||||
const empty = { list: [] as any[], allPage: 1, total: 0, limit, source: 'qian' as const }
|
||||
const empty = { list: [] as any[], allPage: 1, total: 0, limit, source: 'bd' as const }
|
||||
if (!word) return empty
|
||||
|
||||
const body = await apiGet('/v1/search', {
|
||||
@@ -28,7 +28,7 @@ export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
allPage: haveMore ? page + 1 : Math.max(page, Math.ceil(total / Math.max(1, limit)) || 1),
|
||||
total,
|
||||
limit,
|
||||
source: 'qian' as const,
|
||||
source: 'bd' as const,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export const parseSong = async(link: string) => {
|
||||
}
|
||||
delete song.lyricUrl
|
||||
delete song.raw
|
||||
return { ...song, songmid: firstNonEmpty(song.songmid, tsid), source: 'qian' as const }
|
||||
return { ...song, songmid: firstNonEmpty(song.songmid, tsid), source: 'bd' as const }
|
||||
}
|
||||
|
||||
export const parseAlbum = async(link: string) => parseAlbumLink(link)
|
||||
|
||||
@@ -19,14 +19,14 @@ const mapPlaylistSummary = (item: any) => {
|
||||
creator: firstNonEmpty(item?.creator, item?.author, item?.userName, item?.nickName, item?.ownerName),
|
||||
description: rawDesc || tagList.join('、'),
|
||||
link: id ? playlistLink(id) : '',
|
||||
source: 'qian' as const,
|
||||
source: 'bd' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 SearchPlaylist(type=6 歌单搜索;服务端已停用,返回空列表) */
|
||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 10) => {
|
||||
const word = String(keyword ?? '').trim()
|
||||
const empty = { source: 'qian' as const, list: [] as any[], total: 0, page, limit }
|
||||
const empty = { source: 'bd' as const, list: [] as any[], total: 0, page, limit }
|
||||
if (!word) return empty
|
||||
|
||||
const body = await apiGet('/v1/search', {
|
||||
@@ -40,7 +40,7 @@ export const searchPlaylist = async(keyword: string, page = 1, limit = 10) => {
|
||||
|
||||
const items = Array.isArray(body?.data?.typeSonglist) ? body.data.typeSonglist : []
|
||||
const list = items.map(mapPlaylistSummary).filter((item: any) => item.id)
|
||||
return { source: 'qian' as const, list, total: Number(body?.data?.total) || list.length, page, limit }
|
||||
return { source: 'bd' as const, list, total: Number(body?.data?.total) || list.length, page, limit }
|
||||
}
|
||||
|
||||
/** 歌单详情(含曲目;tracklist/info 支持 pageNo/pageSize) */
|
||||
@@ -69,7 +69,7 @@ export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
|
||||
const total = Number(data?.trackCount) || list.length
|
||||
|
||||
return {
|
||||
source: 'qian' as const,
|
||||
source: 'bd' as const,
|
||||
info: { id: playlistId, name: title, img: cover, desc: description, author: creator, link: playlistLink(playlistId) },
|
||||
list,
|
||||
page,
|
||||
@@ -105,7 +105,7 @@ export const getCategories = async() => {
|
||||
list.push({ id, name, group: groupName, count: Number(item?.count ?? 0) || 0 })
|
||||
}
|
||||
}
|
||||
return { source: 'qian' as const, list }
|
||||
return { source: 'bd' as const, list }
|
||||
}
|
||||
|
||||
/** 上游 GetCategoryPlaylists(page >= 1,limit 1..100) */
|
||||
@@ -125,7 +125,7 @@ export const getCategoryPlaylists = async(categoryId: string | number, page = 1,
|
||||
.map((item: any) => ({ ...mapPlaylistSummary(item), categoryId: cateId }))
|
||||
.filter((item: any) => item.id)
|
||||
return {
|
||||
source: 'qian' as const,
|
||||
source: 'bd' as const,
|
||||
list,
|
||||
total: Number(body?.data?.total) || list.length,
|
||||
page: pageNo,
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
// 上游 music-lib/qianqian 的全部接口都走 /v1/*,参数 = 业务参数 + appid + timestamp + sign:
|
||||
// sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret)
|
||||
// 该平台匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 仅影响 VIP、收藏等登录态资源,
|
||||
// MusicServer 从 sdk/auth/store.ts 的 getCredentials('qian') 读取(可选,未登录留空)。
|
||||
// MusicServer 从 sdk/auth/store.ts 的 getCredentials('bd') 读取(可选,未登录留空)。
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
@@ -18,7 +18,7 @@ export const RATE_ORDER = ['3000', '320', '128', '64']
|
||||
|
||||
/** 千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致) */
|
||||
export const getQianCookie = (): string => {
|
||||
const cred = getCredentials('qian') as { cookie?: string } | null
|
||||
const cred = getCredentials('bd') as { cookie?: string } | null
|
||||
return cred?.cookie ? String(cred.cookie) : ''
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ export const mapTrack = (item: any, opts: MapTrackOptions = {}) => {
|
||||
lrcx: '',
|
||||
types,
|
||||
_types,
|
||||
source: 'qian' as const,
|
||||
source: 'bd' as const,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
/** 音乐平台标识 */
|
||||
export type Source =
|
||||
| 'kw' | 'kg' | 'tx' | 'wy' | 'qs'
|
||||
| 'migu' | 'qian' | 'joox' | 'bili' | '5sing' | 'apple'
|
||||
| 'migu' | 'bd' | 'jx' | 'bl' | '5s' | 'apple'
|
||||
| 'xm' | 'me' | 'qt' | 'lr'
|
||||
|
||||
/** 音质标识(wav 仅部分接口声明使用) */
|
||||
|
||||
Reference in new issue
Block a user