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
+62
View File
@@ -0,0 +1,62 @@
export function sanitize(name: string): string {
return name.replace(/[/\\:*?"<>|]/g, '_').trim();
}
export function sanitizePathSegment(name: unknown, fallback: string): string {
const value = sanitize(String(name || '')).replace(/[. ]+$/g, '');
return value && value !== '.' && value !== '..' ? value : fallback;
}
// "A/B" "A /B" -> "A / B"
export function formatPersonNames(value: string): string;
export function formatPersonNames(value: string | undefined): string | undefined;
export function formatPersonNames(value: string | undefined): string | undefined {
return typeof value === 'string' ? value.replace(/\s*\/\s*/g, ' / ') : value;
}
export function joinNames(names: Array<{ name?: string } | string | undefined> | undefined): string {
return (names || [])
.map((item) => (typeof item === 'string' ? item : item?.name))
.filter(Boolean)
.join(' / ');
}
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(signal!.reason);
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}
export function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
// 简单的带过期时间的内存缓存,用于专辑详情等批量下载时反复请求的数据
export class TtlCache<V> {
private map = new Map<string, { value: V; expires: number }>();
private readonly ttlMs: number;
private readonly max: number;
constructor(ttlMs: number, max = 500) {
this.ttlMs = ttlMs;
this.max = max;
}
async getOrSet(key: string, load: () => Promise<V>): Promise<V> {
const hit = this.map.get(key);
if (hit && hit.expires > Date.now()) return hit.value;
const value = await load();
if (this.map.size >= this.max) this.map.delete(this.map.keys().next().value!);
this.map.set(key, { value, expires: Date.now() + this.ttlMs });
return value;
}
}