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
+89
View File
@@ -0,0 +1,89 @@
import type { QualityKey, Source } from './api.ts';
export const SOURCE_LABEL: Record<Source, string> = { netease: '网易云', qq: 'QQ 音乐' };
export const QUALITY_BADGE: Record<QualityKey, string> = {
standard: '128K',
high: '320K',
lossless: 'FLAC',
hires: 'HI-RES',
master: 'MASTER',
spatial: 'SPATIAL',
effect: 'EFFECT',
};
export const QUALITY_LABEL: Record<QualityKey, string> = {
standard: '标准',
high: '极高',
lossless: '无损',
hires: 'Hi-Res',
master: '超清母带',
spatial: '空间音频',
effect: '高清臻品',
};
const QUALITY_RANK: QualityKey[] = ['effect', 'spatial', 'master', 'hires', 'lossless', 'high', 'standard'];
export function isGold(q: string | null | undefined): boolean {
return q === 'hires' || q === 'master' || q === 'spatial' || q === 'effect';
}
/** 该曲目声明的最佳音质(用于徽章) */
export function bestQuality(qs: QualityKey[] | undefined): QualityKey | undefined {
if (!qs?.length) return undefined;
return QUALITY_RANK.find((q) => qs.includes(q));
}
export function formatDuration(ms?: number | null): string {
if (!ms) return '--:--';
const s = Math.round(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
export function formatSeconds(sec: number): string {
if (!Number.isFinite(sec)) return '0:00';
const s = Math.floor(sec);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
}
export function formatSize(bytes?: number | null): string {
if (!bytes) return '—';
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(2)} GB`;
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
export function formatSpeed(bps: number): string {
return bps >= 1048576 ? `${(bps / 1048576).toFixed(1)} MB/s` : `${Math.round(bps / 1024)} KB/s`;
}
export function formatDate(ts?: number | string | null): string {
if (!ts) return '';
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? '' : `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
const dtf = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
export function formatDateTime(ts: string): string {
return dtf.format(new Date(ts));
}
export function coverThumb(url: string | undefined | null, size = 120): string | undefined {
if (!url) return undefined;
const https = url.replace(/^http:/, 'https:');
if (/music\.126\.net/.test(https)) return `${https.split('?')[0]}?param=${size}y${size}`;
if (/y\.gtimg\.cn\/music\/photo_new\/T00\dR\d+x\d+/.test(https)) {
const s = size <= 150 ? 150 : size <= 300 ? 300 : 800;
return https.replace(/R\d+x\d+/, `R${s}x${s}`);
}
return https;
}
export const STAGE_LABEL: Record<string, string> = {
resolve: '解析音源',
metadata: '获取信息',
download: '下载中',
lyric: '保存歌词',
cover: '获取封面',
tag: '写入标签',
};