对外 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 均无残留。
255 lines
12 KiB
TypeScript
255 lines
12 KiB
TypeScript
// HTTP 服务器入口(Fastify)—— 多平台音乐聚合 API
|
||
// 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档(校验与文档均由 JSON Schema 驱动)
|
||
//
|
||
// GET /search/:source/:keyword[/:page] 搜索(source 为平台 id 或 all 聚合)
|
||
// GET /find/:source/:keyword 跨平台精确匹配
|
||
// GET /url/:source/:id 播放地址
|
||
// GET /lyric/:source/:id 歌词
|
||
// GET /pic/:source/:id 封面
|
||
// GET /boards/:source 榜单列表
|
||
// GET /boards/:source/:boardId[/:page] 榜单详情
|
||
// GET /hot-search/:source 热搜词
|
||
// GET /song-list/:source[/:page] 歌单列表
|
||
// GET /singer/:source/:id 歌手信息(kg/tx/wy)
|
||
// GET /singer/:source/:id/songs[/:page] 歌手歌曲(kg/tx/wy)
|
||
// GET /singer/:source/:id/albums[/:page] 歌手专辑(kg/tx/wy)
|
||
// GET /album/:source/:id[/:page] 专辑详情+曲目
|
||
// GET /album/:source/search/:keyword 专辑搜索
|
||
// GET /playlist/:source/:id[/:page] 歌单详情+曲目
|
||
// GET /playlist/:source/search/:keyword 歌单搜索
|
||
// GET /playlist/:source/categories[/:categoryId] 歌单分类
|
||
// POST /parse/{song|album|playlist}/:source 链接解析
|
||
// GET /user-playlist/:source 个人歌单
|
||
// GET /stream/:source/:id 解密流代理(source: qs 汽水 / migu 咪咕)
|
||
// GET /sources /health GET /docs /redoc /openapi.json
|
||
//
|
||
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
|
||
// 响应信封:成功 { code: 0, data };失败 { code: <HTTP状态码>, msg, detail? }
|
||
|
||
import './env.ts' // 必须最先导入:在任何读取 process.env 的模块求值前加载 .env
|
||
import Fastify from 'fastify'
|
||
import cors from '@fastify/cors'
|
||
import swagger from '@fastify/swagger'
|
||
import swaggerUi from '@fastify/swagger-ui'
|
||
import { readFileSync } from 'node:fs'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { dirname, join } from 'node:path'
|
||
import musicSdk from '../sdk/index.ts'
|
||
import sourceApi from './source.ts'
|
||
import { ApiError } from './http/api-error.ts'
|
||
import { musicRoutes } from './routes.ts'
|
||
import { loginRoutes } from './routes-login.ts'
|
||
import { startRefreshScheduler } from './refresh.ts'
|
||
import { COMPONENT_SCHEMAS } from './openapi/components.ts'
|
||
import { redocHtml } from './openapi/ui.ts'
|
||
|
||
// 默认端口 16688:本机 TCP 动态端口范围是 1024~15000(出站连接会随机占用低端端口),避开该区间
|
||
const PORT = process.env.PORT || 16688
|
||
const DOCS_PREFIX = '/docs'
|
||
const API_TITLE = 'MusicServer 聚合音乐 API'
|
||
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析。' +
|
||
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)/ migu(咪咕)/ bd(千千)/ ' +
|
||
'jx / bl(哔哩哔哩)/ 5s / apple(Apple Music,仅试听)与有声平台 xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。'
|
||
|
||
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'))
|
||
|
||
const fastify = Fastify({
|
||
logger: false,
|
||
// schema 中的 example 等文档关键字仅供 OpenAPI 使用,关闭 Ajv 严格模式以免校验构建报错
|
||
ajv: { customOptions: { strict: false } },
|
||
})
|
||
fastify.decorate('musicSdk', musicSdk)
|
||
|
||
// ---- 音源模式(服务端职责):SOURCE_ENABLED 非 0/false 时,/url 取流重定向到 sources/ 外部音源脚本 ----
|
||
// SDK 只包含官方平台接口;是否走外部音源由服务端在此决策,路由层按 sourceApi 是否挂载分流
|
||
const sourceEnabled = process.env.SOURCE_ENABLED
|
||
const useSource = Boolean(sourceEnabled) && sourceEnabled !== '0' && sourceEnabled !== 'false'
|
||
fastify.decorate('sourceApi', useSource ? sourceApi : null)
|
||
|
||
// ---- 共享 schema(领域模型:校验引用与 OpenAPI components 同源) ----
|
||
for (const [name, schema] of Object.entries(COMPONENT_SCHEMAS)) {
|
||
fastify.addSchema({ ...schema, $id: name })
|
||
}
|
||
|
||
// ---- 全局中间行为 ----
|
||
|
||
// 收集全部路由模式(含 :param 参数段),供 not-found 处理器做 405 判定
|
||
const routePatterns: { method: any; regex: RegExp }[] = []
|
||
fastify.addHook('onRoute', (routeOptions) => {
|
||
if (!routeOptions.url || !routeOptions.method) return
|
||
// :param 段 → [^/]+;通配符 * → .*(如 @fastify/cors 注册的 catch-all 路由);其余按字面量转义
|
||
const source = routeOptions.url
|
||
.replace(/:[^/]+/g, '\u0000')
|
||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
.replace(/\u0000/g, '[^/]+')
|
||
.replace(/\\\*/g, '.*')
|
||
routePatterns.push({ method: routeOptions.method, regex: new RegExp(`^${source}$`) })
|
||
})
|
||
|
||
fastify.register(cors, { origin: '*' })
|
||
|
||
// ---- 全局限流(per-IP 滑动窗口,RATE_LIMIT=每分钟请求数,0 关闭;默认 120)----
|
||
// 先于鉴权计数:401 爆破秘钥同样受限
|
||
const rateLimit = Number(process.env.RATE_LIMIT ?? 120)
|
||
if (rateLimit > 0) {
|
||
const { registerRateLimit } = await import('./http/rate-limit.ts')
|
||
registerRateLimit(fastify, rateLimit)
|
||
}
|
||
|
||
// ---- 调用秘钥(AUTH_KEY 环境变量启用;未设置则不鉴权)----
|
||
// 统一 X-Server-Key 头,全部路径(含文档/健康检查/404)均要求秘钥,防扫描探测;详见 src/http/auth-gate.ts
|
||
const authKey = process.env.AUTH_KEY
|
||
if (authKey) {
|
||
const { registerAuth } = await import('./http/auth-gate.ts')
|
||
registerAuth(fastify, authKey)
|
||
}
|
||
|
||
fastify.addHook('onRequest', async(_request, reply) => {
|
||
reply.header('Cache-Control', 'no-store')
|
||
})
|
||
|
||
fastify.addHook('onResponse', async(request, reply) => {
|
||
console.log(`[${new Date().toISOString()}] ${request.method} ${request.url} → ${reply.statusCode} (${Math.round(reply.elapsedTime)}ms)`)
|
||
})
|
||
|
||
// 成功响应统一包成 { code: 0, data };文档页/raw 路由与非 200 响应不包。
|
||
// @fastify/swagger-ui 的规范端点 {DOCS_PREFIX}/json 由插件内部注册、无法加 raw 标记,
|
||
// 必须豁免:Swagger UI 强制从该地址加载规范,包了信封顶层就没有 openapi 字段,页面直接报错
|
||
fastify.addHook('preSerialization', async(request, reply, payload) => {
|
||
if (reply.statusCode !== 200) return payload
|
||
if ((request.routeOptions.config as any)?.raw) return payload
|
||
if (request.routeOptions.url === `${DOCS_PREFIX}/json`) return payload
|
||
if (payload === null || typeof payload === 'object') return { code: 0, data: payload }
|
||
return payload
|
||
})
|
||
|
||
// ---- 错误处理 ----
|
||
|
||
fastify.setErrorHandler((err: any, request, reply) => {
|
||
if (err instanceof ApiError) {
|
||
const payload: Record<string, any> = { code: err.status, msg: err.message }
|
||
if (err.detail !== undefined) payload.detail = err.detail
|
||
return reply.code(err.status).send(payload)
|
||
}
|
||
// JSON Schema 校验失败(FastAPI 同款 422 语义)
|
||
if (err.validation) {
|
||
return reply.code(422).send({ code: 422, msg: err.message, detail: err.validation })
|
||
}
|
||
console.error(`[${new Date().toISOString()}] ${request.method} ${request.url} 内部错误:`, err)
|
||
return reply.code(500).send({ code: 500, msg: '服务器内部错误' })
|
||
})
|
||
|
||
fastify.setNotFoundHandler((request, reply) => {
|
||
// 路径存在但方法不允许 → 405 + Allow 头(Fastify 默认给 404,这里补齐标准语义)
|
||
// 用模式匹配而非 hasRoute:hasRoute 需要精确 URL,匹配不到 /url/:source/:id 这类参数路由
|
||
const pathname = new URL(request.url, 'http://localhost').pathname
|
||
const allowed = [...new Set(
|
||
routePatterns
|
||
.filter(r => r.regex.test(pathname) && r.method !== request.method)
|
||
// OPTIONS/HEAD 是辅助方法(CORS 预检由 @fastify/cors 的通配路由承接),不应计入 405 判定
|
||
.filter(r => r.method !== 'OPTIONS' && r.method !== 'HEAD')
|
||
.map(r => r.method),
|
||
)]
|
||
if (allowed.length) {
|
||
reply.code(405).header('Allow', allowed.join(', '))
|
||
return reply.send({ code: 405, msg: `方法 ${request.method} 不允许,请使用 ${allowed.join(', ')}` })
|
||
}
|
||
reply.code(404).send({ code: 404, msg: `接口不存在: ${request.url}` })
|
||
})
|
||
|
||
// ---- OpenAPI 文档 ----
|
||
|
||
fastify.register(swagger, {
|
||
// 组件按 addSchema 的 $id 命名(默认 def-1/def-2 不可读)
|
||
refResolver: {
|
||
buildLocalReference: (json: any, _baseUri: any, _fragment: any, i: number) => json.$id || `def-${i}`,
|
||
},
|
||
openapi: {
|
||
openapi: '3.1.0',
|
||
info: {
|
||
title: API_TITLE,
|
||
description: API_DESCRIPTION,
|
||
version: pkg.version,
|
||
},
|
||
servers: [{ url: '/', description: '当前服务器' }],
|
||
tags: [
|
||
{ name: '搜索', description: '歌曲搜索与跨平台匹配' },
|
||
{ name: '歌曲资源', description: '播放地址、歌词、封面' },
|
||
{ name: '榜单', description: '排行榜' },
|
||
{ name: '歌单', description: '歌单与热搜' },
|
||
{ name: '歌手专辑', description: '歌手信息、歌曲与专辑详情' },
|
||
{ name: '登录', description: '账号登录(wy 扫码/手机号、kg 扫码/token、tx 扫码/cookie、qs 抖音扫码)与状态' },
|
||
{ name: '系统', description: '健康检查、平台信息、文档' },
|
||
],
|
||
},
|
||
})
|
||
|
||
// 注意:插件初始化器会把规范地址强制为 {DOCS_PREFIX}/json(uiConfig 的 url 会被覆盖),
|
||
// 真正对外文档化的规范地址是下方带 raw 标记的 /openapi.json
|
||
fastify.register(swaggerUi, { routePrefix: DOCS_PREFIX })
|
||
|
||
fastify.get('/openapi.json', {
|
||
config: { raw: true },
|
||
schema: {
|
||
summary: 'OpenAPI 3.1 规范文档',
|
||
description: '机器可读的接口描述,可用于生成各语言客户端。',
|
||
tags: ['系统'],
|
||
},
|
||
}, async() => fastify.swagger())
|
||
|
||
fastify.get('/redoc', {
|
||
schema: {
|
||
summary: 'ReDoc 阅读版文档',
|
||
tags: ['系统'],
|
||
},
|
||
}, async(_request, reply) => reply.type('text/html; charset=utf-8').send(redocHtml({ title: API_TITLE })))
|
||
|
||
fastify.get('/', {
|
||
schema: {
|
||
summary: 'API 文档入口',
|
||
description: '302 重定向到 Swagger UI 文档页。',
|
||
tags: ['系统'],
|
||
},
|
||
}, async(_request, reply) => reply.redirect('/docs'))
|
||
|
||
// ---- 业务路由 ----
|
||
|
||
fastify.register(musicRoutes)
|
||
fastify.register(loginRoutes)
|
||
|
||
// 登录态自动续期(onClose 钩子需在 listen 前注册,故这里而非 startServer 内启动)。
|
||
// 音源模式下取流走外部脚本,登录态不参与任何接口,自动续期没有意义;手动 /login/refresh 仍可用
|
||
if (!useSource) {
|
||
startRefreshScheduler(fastify)
|
||
} else {
|
||
console.log(`[${new Date().toISOString()}] [登录续期] 音源模式已启用,登录态不参与取流,自动续期未启动`)
|
||
}
|
||
|
||
export const startServer = async(port = PORT) => {
|
||
await fastify.listen({ port: Number(port), host: '0.0.0.0' })
|
||
console.log(`音乐服务器已启动: http://localhost:${port}`)
|
||
console.log(`调用鉴权: ${authKey ? '已启用(AUTH_KEY,X-Server-Key 头)' : '未启用(设置 AUTH_KEY 环境变量开启)'}`)
|
||
console.log(`限流: ${rateLimit > 0 ? `${rateLimit} 次/分钟/IP(RATE_LIMIT 可调,0 关闭)` : '未启用'}`)
|
||
console.log(`取流通道: ${useSource ? '外部音源脚本(sources/,SOURCE_ENABLED)' : 'SDK 官方直连接口'}`)
|
||
console.log(`接口文档: http://localhost:${port}/docs (Swagger UI)`)
|
||
console.log(`ReDoc 文档: http://localhost:${port}/redoc`)
|
||
console.log(`OpenAPI: http://localhost:${port}/openapi.json`)
|
||
return fastify
|
||
}
|
||
|
||
const shutdown = (signal) => {
|
||
console.log(`\n收到 ${signal},正在关闭服务器...`)
|
||
fastify.close().then(() => {
|
||
console.log('服务器已关闭')
|
||
process.exit(0)
|
||
})
|
||
setTimeout(() => process.exit(0), 3000).unref()
|
||
}
|
||
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||
process.on('SIGINT', () => shutdown('SIGINT'))
|
||
|
||
// 直接运行时启动
|
||
if (process.argv[1] && (process.argv[1].endsWith('server.ts') || process.argv[1].endsWith('src\\server.ts'))) {
|
||
startServer()
|
||
}
|