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
+201
View File
@@ -0,0 +1,201 @@
// 单曲下载流水线(由旧版 lib/downloader.js 泛化而来,与平台无关):
// 解析音源 → 元数据 → 同名文件检查 → 流式下载 → 歌词 → 专辑封面 → 写标签
import fs from 'node:fs';
import path from 'node:path';
import type { Layout } from '../config.ts';
import { resolveTarget } from './layout.ts';
import { mergeLrc, parseCredits } from './lyrics.ts';
import { downloadToFile, fetchBuffer, type Progress } from './net/http.ts';
import type { QualityKey } from './quality.ts';
import { embedTags, type TagMeta } from './tagging.ts';
import { FatalError, type MusicProvider, type TrackMeta } from './types.ts';
import { errorMessage, sleep } from './util.ts';
export interface DownloadOptions {
quality: QualityKey;
layout: Layout;
retries: number;
lyric: boolean;
translatedLyric: boolean;
embedTags: boolean;
albumCover: boolean;
}
const TAG_OVERHEAD_MAX = 8 * 1024 * 1024;
export type Stage ='resolve' | 'metadata' | 'download' | 'lyric' | 'cover' | 'tag';
export interface Reporter {
stage?(stage: Stage, message: string): void;
progress?(p: Progress): void;
log?(level: 'info' | 'warn', message: string): void;
}
export interface DownloadResult {
source: string;
id: string;
name: string;
artists: string;
album: string;
quality: QualityKey;
bitrate?: number;
size: number;
file: string; // 绝对路径
relFile: string; // 相对音乐目录
skipped: boolean;
warnings: string[];
}
interface Context {
musicDir: string;
signal?: AbortSignal;
reporter?: Reporter;
}
export async function downloadTrack(
provider: MusicProvider,
id: string,
opts: DownloadOptions,
ctx: Context,
): Promise<DownloadResult> {
let lastError: unknown;
for (let attempt = 1; attempt <= opts.retries; attempt++) {
try {
return await downloadOnce(provider, id, opts, ctx);
} catch (e) {
lastError = e;
if (e instanceof FatalError || ctx.signal?.aborted) throw e;
if (attempt < opts.retries) {
const waitSec = attempt * 3;
ctx.reporter?.log?.('warn', `第 ${attempt} 次失败:${errorMessage(e)},${waitSec} 秒后重试`);
await sleep(waitSec * 1000, ctx.signal);
}
}
}
throw lastError;
}
async function downloadOnce(provider: MusicProvider, id: string, opts: DownloadOptions, ctx: Context): Promise<DownloadResult> {
const { reporter, signal } = ctx;
const warnings: string[] = [];
const warn = (msg: string) => {
warnings.push(msg);
reporter?.log?.('warn', msg);
};
reporter?.stage?.('resolve', '解析音源');
const audio = await provider.resolveAudio(id, opts.quality, signal);
reporter?.stage?.('metadata', '获取元数据');
const meta = await provider.getTrackMeta(id).catch(() => null);
const name = audio.name || meta?.name || id;
const artists = audio.artists || meta?.artists || '未知歌手';
const album = audio.album || meta?.album || '';
const target = resolveTarget(ctx.musicDir, opts.layout, {
name,
artists,
album,
albumArtistPrimary: meta?.albumArtistPrimary,
trackNo: meta?.trackNo,
trackTotal: meta?.trackTotal,
});
await fs.promises.mkdir(target.dir, { recursive: true });
const dest = path.join(target.dir, `${target.baseName}.${audio.ext}`);
// 同名文件视为已下载的条件:不小于音源的 99%,且最多比音源大 8MB(嵌入的封面 + 歌词会让文件变大)。
// 下载先写 .part 再改名,不会留下半截文件。
let skipped = false;
const existing = await fs.promises.stat(dest).catch(() => null);
if (existing && (!audio.size || (existing.size >= audio.size * 0.99 && existing.size <= audio.size + TAG_OVERHEAD_MAX))) {
skipped = true;
reporter?.log?.('info', '文件已存在,跳过下载');
} else {
if (existing) reporter?.log?.('info', '已有同名文件但大小不同,覆盖下载');
reporter?.stage?.('download', '下载音频');
await downloadToFile(audio.url, dest, { signal, onProgress: (p) => reporter?.progress?.(p) });
}
let lrc: string | undefined;
if (opts.lyric) {
reporter?.stage?.('lyric', '保存歌词');
const lyric = await provider.getLyric(id);
if (lyric?.lrc) {
lrc = lyric.lrc;
await fs.promises.writeFile(path.join(target.dir, `${target.baseName}.lrc`), lyric.lrc, 'utf-8');
if (opts.translatedLyric && lyric.tlyric?.trim()) {
await fs.promises.writeFile(
path.join(target.dir, `${target.baseName}.合并翻译.lrc`),
mergeLrc(lyric.lrc, lyric.tlyric),
'utf-8',
);
}
} else {
warn('未获取到歌词');
}
}
const coverUrl = meta?.cover || audio.cover;
const wantAlbumCover = opts.albumCover && target.isAlbumDir;
let cover: Buffer | null = null;
if (coverUrl && (opts.embedTags || wantAlbumCover)) {
reporter?.stage?.('cover', '获取封面');
cover = await fetchBuffer(coverUrl, { signal }).catch((e) => {
warn(`封面下载失败:${errorMessage(e)}`);
return null;
});
}
if (cover && wantAlbumCover) {
const coverPath = path.join(target.dir, 'cover.jpg');
const stat = await fs.promises.stat(coverPath).catch(() => null);
if (!stat?.size) await fs.promises.writeFile(coverPath, cover);
}
if (opts.embedTags) {
reporter?.stage?.('tag', '写入标签');
try {
const method = await embedTags(dest, cover, buildTagMeta(name, artists, album, meta, lrc));
reporter?.log?.('info', `封面+标签+歌词已嵌入 (${method})`);
} catch (e) {
warn(`标签写入失败:${errorMessage(e)}`);
}
}
const size = (await fs.promises.stat(dest)).size;
return {
source: provider.source,
id,
name,
artists,
album,
quality: audio.quality,
bitrate: audio.bitrate,
size,
file: dest,
relFile: path.relative(ctx.musicDir, dest).split(path.sep).join('/'),
skipped,
warnings,
};
}
/** 组装标签字段,规则同旧版 downloader.js */
export function buildTagMeta(
name: string,
artist: string,
album: string,
meta: TrackMeta | null,
lrc: string | undefined,
): TagMeta {
const tag: TagMeta = { name, artist, album, lyrics: lrc, ...parseCredits(lrc) };
if (!meta) return tag;
if (meta.trackNo) tag.trackNo = meta.trackTotal ? `${meta.trackNo}/${meta.trackTotal}` : String(meta.trackNo);
if (meta.disc) tag.disc = meta.disc;
if (meta.albumArtist) tag.albumArtist = meta.albumArtist;
if (meta.compilation) tag.compilation = true;
if (meta.year) tag.year = meta.year;
if (meta.date) tag.date = meta.date;
if (meta.genre) tag.genre = meta.genre;
if (meta.publisher) tag.publisher = meta.publisher;
return tag;
}