feat: 声匣 —— 网易云 / QQ 音乐自托管下载服务

由 163-music-dl CLI 重构为 Web 服务:
- 下载流水线、歌词与标签逻辑沿用旧版(node-id3 / ffmpeg)
- 网易云(chksz 接口 + music.163.com 元数据)与 QQ 音乐(VIP Cookie)provider
- QQ 扫码登录与 musickey 自动续期
- SQLite 持久化的下载队列,SSE 实时进度
- 曲库工具(M3U / ZIP / 回收站 / 补全标签)、历史统计与导出、访问密码
- Vite 8 + Vue 3 前端,浅色 / 深色
- Docker 部署配置(飞牛 NAS)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jocay
2026-10-10 22:05:30 +08:00
commit 21945cc54f
73 changed files with 12507 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
// 试听代理:解析出 320K 音源后由服务端转发(支持 Range),绕过防盗链与混合内容
import { Readable } from 'node:stream';
import type { Context } from 'koa';
import { UA } from './core/net/http.ts';
import { getProvider } from './core/providers/index.ts';
import { TtlCache } from './core/util.ts';
const urlCache = new TtlCache<{ url: string; ext: string }>(3 * 60 * 1000, 200);
const MIME: Record<string, string> = { mp3: 'audio/mpeg', flac: 'audio/flac', m4a: 'audio/mp4', ogg: 'audio/ogg' };
export async function proxyStream(ctx: Context, source: string, id: string) {
const provider = getProvider(source);
const audio = await urlCache.getOrSet(`${source}:${id}`, async () => {
const r = await provider.resolveAudio(id, 'high');
return { url: r.url, ext: r.ext };
});
const controller = new AbortController();
ctx.req.on('close', () => controller.abort());
const headers: Record<string, string> = { 'User-Agent': UA };
if (ctx.get('range')) headers.Range = ctx.get('range');
const upstream = await fetch(audio.url, { headers, signal: controller.signal });
if (!upstream.ok || !upstream.body) ctx.throw(502, `音源请求失败 (HTTP ${upstream.status})`);
ctx.status = upstream.status;
ctx.set('Content-Type', MIME[audio.ext] || upstream.headers.get('content-type') || 'application/octet-stream');
ctx.set('Accept-Ranges', 'bytes');
ctx.set('Cache-Control', 'no-store');
for (const h of ['content-length', 'content-range']) {
const v = upstream.headers.get(h);
if (v) ctx.set(h, v);
}
ctx.body = Readable.fromWeb(upstream.body as any);
}