Files
music-hub/server/src/core/pipeline.ts
T
Jocay 9f908fa73f feat: 下载沿用曲库已有的歌手 / 专辑文件夹写法
歌手或专辑文件夹名与曲库已有文件夹只差空格、大小写、全半角时(如
「G.E.M.邓紫棋」与「G.E.M. 邓紫棋」),放进已有文件夹,并把歌手、
专辑歌手标签统一成该写法,避免同一歌手被拆成两个目录 / 两个艺人。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:29:51 +08:00

231 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 单曲下载流水线(由旧版 lib/downloader.js 泛化而来,与平台无关):
// 解析音源 → 元数据 → 同名文件检查 → 流式下载 → 歌词 → 专辑封面 → 写标签
import fs from 'node:fs';
import path from 'node:path';
import type { Layout } from '../config.ts';
import { applyArtistAlias, 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 const FOLDER_IMAGES = ['artist.jpg', 'folder.jpg', 'cover.jpg', 'cover.png', 'artist.png'];
export async function hasFolderImage(dir: string): Promise<boolean> {
for (const name of FOLDER_IMAGES) {
const stat = await fs.promises.stat(path.join(dir, name)).catch(() => null);
if (stat?.size) return true;
}
return false;
}
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;
let 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,
});
// 沿用了曲库已有的歌手文件夹名(如「G.E.M. 邓紫棋」)时,标签里的歌手名也统一成该写法
if (target.artistAlias) {
artists = applyArtistAlias(artists, target.artistAlias)!;
if (meta) {
meta.albumArtist = applyArtistAlias(meta.albumArtist, target.artistAlias);
meta.albumArtistPrimary = target.artistAlias.to;
}
reporter?.log?.('info', `歌手名统一为曲库已有写法:${target.artistAlias.to}`);
}
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);
}
// 歌手目录还没有头像时写入 artist.jpg(合辑「群星」不写)
if (wantAlbumCover && meta?.albumArtistImage && !meta.compilation) {
const artistDir = path.dirname(target.dir);
if (!(await hasFolderImage(artistDir))) {
const avatar = await fetchBuffer(meta.albumArtistImage, { signal }).catch(() => null);
if (avatar?.length) await fs.promises.writeFile(path.join(artistDir, 'artist.jpg'), avatar);
}
}
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;
}