feat: 下载沿用曲库已有的歌手 / 专辑文件夹写法

歌手或专辑文件夹名与曲库已有文件夹只差空格、大小写、全半角时(如
「G.E.M.邓紫棋」与「G.E.M. 邓紫棋」),放进已有文件夹,并把歌手、
专辑歌手标签统一成该写法,避免同一歌手被拆成两个目录 / 两个艺人。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jocay
2026-10-10 23:29:51 +08:00
parent 4a8fe455f3
commit 9f908fa73f
2 changed files with 53 additions and 6 deletions
+42 -4
View File
@@ -1,6 +1,8 @@
// 输出目录与文件名规则
// album: {音乐目录}/{第一位专辑艺人}/{专辑}/{NN}. {歌名}.ext —— 与 NAS 现有曲库结构一致
// flat : {音乐目录}/{歌手} - {歌名}.ext —— 旧版单曲下载格式
// 曲库里已有只差空格 / 大小写 / 全半角的歌手或专辑文件夹时,沿用已有文件夹名,避免同一歌手拆成两个目录
import fs from 'node:fs';
import path from 'node:path';
import type { Layout } from '../config.ts';
import { sanitize, sanitizePathSegment } from './util.ts';
@@ -18,6 +20,26 @@ export interface Target {
dir: string;
baseName: string; // 不含扩展名
isAlbumDir: boolean;
/** 歌手名被替换为曲库已有写法时:{ from: 平台写法, to: 曲库写法 } */
artistAlias?: { from: string; to: string };
}
/** 判断两个文件夹名是否「同一个」:忽略空白、大小写、全半角 */
export function folderKey(name: string): string {
return name.normalize('NFKC').toLowerCase().replace(/\s+/g, '');
}
/** 在 parent 下找与 name 等价的已有文件夹,找到返回已有的名字 */
function existingFolder(parent: string, name: string): string {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(parent, { withFileTypes: true });
} catch {
return name;
}
if (entries.some((e) => e.isDirectory() && e.name === name)) return name;
const key = folderKey(name);
return entries.find((e) => e.isDirectory() && folderKey(e.name) === key)?.name ?? name;
}
export function resolveTarget(musicDir: string, layout: Layout, input: LayoutInput): Target {
@@ -25,13 +47,29 @@ export function resolveTarget(musicDir: string, layout: Layout, input: LayoutInp
if (layout === 'flat') {
return { dir: musicDir, baseName: sanitize(`${input.artists} - ${title}`), isAlbumDir: false };
}
const firstArtist = input.artists.split(' / ')[0];
const artistDir = sanitizePathSegment(input.albumArtistPrimary || firstArtist, '未知歌手');
const albumDir = sanitizePathSegment(input.album, '未知专辑');
const primary = input.albumArtistPrimary || input.artists.split(' / ')[0] || '';
const wantArtistDir = sanitizePathSegment(primary, '未知歌手');
const artistDir = existingFolder(musicDir, wantArtistDir);
const albumDir = existingFolder(path.join(musicDir, artistDir), sanitizePathSegment(input.album, '未知专辑'));
let baseName = title;
if (input.trackNo) {
const width = Math.max(2, String(input.trackTotal || 0).length);
baseName = `${String(input.trackNo).padStart(width, '0')}. ${title}`;
}
return { dir: path.join(musicDir, artistDir, albumDir), baseName: sanitize(baseName), isAlbumDir: true };
return {
dir: path.join(musicDir, artistDir, albumDir),
baseName: sanitize(baseName),
isAlbumDir: true,
// 只有原名本身就是合法文件夹名时才回写标签(含 / 等被替换过的名字不处理)
artistAlias: artistDir !== wantArtistDir && primary === wantArtistDir ? { from: primary, to: artistDir } : undefined,
};
}
/** 把 "A / B" 形式的歌手串中等于 from 的那一位替换为 to */
export function applyArtistAlias(artists: string | undefined, alias: Target['artistAlias']): string | undefined {
if (!artists || !alias) return artists;
return artists
.split(' / ')
.map((a) => (a === alias.from ? alias.to : a))
.join(' / ');
}
+11 -2
View File
@@ -3,7 +3,7 @@
import fs from 'node:fs';
import path from 'node:path';
import type { Layout } from '../config.ts';
import { resolveTarget } from './layout.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';
@@ -100,7 +100,7 @@ async function downloadOnce(provider: MusicProvider, id: string, opts: DownloadO
reporter?.stage?.('metadata', '获取元数据');
const meta = await provider.getTrackMeta(id).catch(() => null);
const name = audio.name || meta?.name || id;
const artists = audio.artists || meta?.artists || '未知歌手';
let artists = audio.artists || meta?.artists || '未知歌手';
const album = audio.album || meta?.album || '';
const target = resolveTarget(ctx.musicDir, opts.layout, {
@@ -111,6 +111,15 @@ async function downloadOnce(provider: MusicProvider, id: string, opts: DownloadO
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}`);