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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user