// 单曲下载流水线(由旧版 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 { 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 { 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 { 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; }