9f908fa73f
歌手或专辑文件夹名与曲库已有文件夹只差空格、大小写、全半角时(如 「G.E.M.邓紫棋」与「G.E.M. 邓紫棋」),放进已有文件夹,并把歌手、 专辑歌手标签统一成该写法,避免同一歌手被拆成两个目录 / 两个艺人。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
231 lines
7.9 KiB
TypeScript
231 lines
7.9 KiB
TypeScript
// 单曲下载流水线(由旧版 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;
|
||
}
|
||
|