Files
MusicServer/sdk/5sing/util.ts
T
snowy-yang 3381e9b906 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 均无残留。
2026-10-03 11:03:06 +08:00

189 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 5sing 公共工具(上游 fivesing/fivesing.go 的 getFirstValid / removeEmTags 等移植)
// 平台 id 统一为 '5s'(上游包名 fivesing,Go 源码 model.Source 用 "fivesing";
// 本仓库契约要求 id 为 5s,故所有对外 source 字段均为 '5s'。
// 上游 New(cookie) 的 cookie 实际参与请求(搜索/getSongUrl/newget/歌单页),
// 这里按约定从 sdk/auth/store.ts 的 getCredentials('5s') 读取(无则匿名)。
import { httpFetch } from '../request.ts'
import { getCredentials } from '../auth/store.ts'
export const UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
/** 上游 fivesing.go 的常量:手机端 API 主机(http 明文,实测无 302) */
export const MOBILE_API = 'http://mobileapi.5sing.kugou.com'
/** 搜索接口(type=0 单曲 / type=1 歌单) */
export const SEARCH_API = 'http://search.5sing.kugou.com/home/json'
/** 作品类型(上游 typeEname;yc=原创 fc=翻唱 bz=伴奏) */
export const SONG_KINDS: Record<string, string> = {
yc: '原创',
fc: '翻唱',
bz: '伴奏',
}
/** 上游 getFirstValid:返回第一个非空字符串(空串视为无效) */
export const getFirstValid = (...urls: Array<string | null | undefined>): string => {
for (const u of urls) {
if (u) return String(u)
}
return ''
}
/** 上游 removeEmTags:去掉搜索结果里 <em class="keyword"> 高亮标签 */
export const removeEmTags = (s: any): string =>
String(s ?? '')
.replaceAll('<em class="keyword">', '')
.replaceAll('</em>', '')
.trim()
const HTML_ENTITIES: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: ' ',
copy: '©',
reg: '®',
hellip: '…',
mdash: '—',
ndash: '–',
}
/** Go html.UnescapeString 的等价实现(常用实体 + 数字实体) */
export const unescapeHtml = (s: any): string =>
String(s ?? '').replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity: string) => {
if (entity.startsWith('#x') || entity.startsWith('#X')) {
const code = Number.parseInt(entity.slice(2), 16)
return Number.isFinite(code) ? String.fromCodePoint(code) : match
}
if (entity.startsWith('#')) {
const code = Number.parseInt(entity.slice(1), 10)
return Number.isFinite(code) ? String.fromCodePoint(code) : match
}
return HTML_ENTITIES[entity.toLowerCase()] ?? match
})
/** 上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用) */
export const get5singCookie = (): string => String((getCredentials('5s') as { cookie?: string } | null)?.cookie ?? '')
/** 登录态(凭据存在性判断,与 me 平台 status 对齐) */
export const getStatus = () => {
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 !== '5s'
/**
* 带 Cookie/UA 的请求(上游 utils.Get + http.Client 自动跟跳的等价实现)
* httpFetch 不跟随 302:这里手动跟跳最多 3 次(5sing 老链接 http→https / 数字 userId→个性域名)。
*/
export const fetch5sing = async(
url: string,
options: { headers?: Record<string, any>, timeout?: number, register?: (request: any) => void } = {},
): Promise<any> => {
const cookie = get5singCookie()
const headers = {
'User-Agent': UserAgent,
...(cookie ? { Cookie: cookie } : {}),
...(options.headers || {}),
}
let current = String(url)
let response: any = null
for (let hop = 0; hop <= 3; hop++) {
const request = httpFetch(current, { headers, timeout: options.timeout ?? 15000 })
options.register?.(request)
response = await request.promise
const status = Number(response?.statusCode) || 0
const location = response?.headers?.location
if (status >= 300 && status < 400 && location) {
try {
current = new URL(String(location), current).href
} catch {
return response
}
continue
}
return response
}
return response
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
/**
* 5sing 手机端 API 有频率控制(实测连续请求会返回 { code: 13, message: "操作太频繁", data: [] }),
* 上游 Go 无重试;本移植对 JSON 接口做最多 2 次退避重试,保证搜索/取流/歌词/歌单在突发请求下稳定。
*/
export const fetch5singJson = async(
url: string,
options: { headers?: Record<string, any>, timeout?: number, register?: (request: any) => void } = {},
): Promise<any> => {
let response: any = null
for (let attempt = 0; attempt < 3; attempt++) {
response = await fetch5sing(url, options)
const code = Number(response?.body?.code)
const message = String(response?.body?.message ?? '')
if ((code === 13 || message.includes('太频繁')) && attempt < 2) {
await sleep(800 * (attempt + 1))
continue
}
return response
}
return response
}
/** 歌曲页链接(上游 Link 拼法:http://5sing.kugou.com/{typeEname}/{songId}.html) */
export const buildSongLink = (songType: string, songId: string | number): string =>
songType && songId ? `http://5sing.kugou.com/${songType}/${songId}.html` : ''
/** 歌单页链接(上游 Link 拼法:http://5sing.kugou.com/{userId}/dj/{playlistId}.html) */
export const buildPlaylistLink = (userId: string | number, playlistId: string): string =>
userId && playlistId ? `http://5sing.kugou.com/${userId}/dj/${playlistId}.html` : ''
/**
* 解析歌曲的 <songid>|<songtype> 约定(上游 model.Song.ID: "123|yc" + Extra{songid,songtype})
* 兼容只给了 songType 字段(路由层只透传 songmid 时用 `|` 分隔)。
*/
export const extractSongId = (
songmid: any,
songTypeFallback?: any,
): { songID: string, songType: string } => {
const raw = String(songmid ?? '')
const [idPart = '', typePart = ''] = raw.split('|')
return {
songID: idPart || '',
songType: String(typePart || songTypeFallback || ''),
}
}
/** 上游 Parse 的正则:5sing.kugou.com/(\w+)/(\d+).html(专辑/歌手页不匹配) */
export const extractSongLink = (link: any): { songType: string, songID: string } | null => {
const match = /5sing\.kugou\.com\/(\w+)\/(\d+)\.html/.exec(String(link ?? ''))
if (!match) return null
return { songType: match[1], songID: match[2] }
}
/**
* 上游 ParsePlaylist 的正则增强:
* 原正则 `5sing\.kugou\.com/(?:(\d+)/)?dj/([a-zA-Z0-9]+)\.html` 只认数字 userId,
* 个性域名(如 /yeluoluo/dj/xxx.html)与省略 user 段的链接会漏;userId 本身不参与请求(API 反查更准),故放宽为任意单段。
*/
export const extractPlaylistLink = (link: any): { userId: string, playlistId: string } | null => {
const match = /5sing\.kugou\.com\/(?:([^/?#]+)\/)?dj\/([a-zA-Z0-9]+)\.html/.exec(String(link ?? ''))
if (!match) return null
return { userId: match[1] || '', playlistId: match[2] }
}
/** 上游 Search 的时长估算:songSize*8/320000(320kbps 假设;songSize 为 0 时返回 0) */
export const estimateDuration = (songSize: number): number => {
const size = Number(songSize) || 0
return size > 0 ? Math.trunc((size * 8) / 320000) : 0
}
/** 作品类型中文名(typeName 缺失时兜底) */
export const kindName = (songType: string): string => SONG_KINDS[songType] ?? ''