feat: 下载沿用曲库已有的歌手 / 专辑文件夹写法
歌手或专辑文件夹名与曲库已有文件夹只差空格、大小写、全半角时(如 「G.E.M.邓紫棋」与「G.E.M. 邓紫棋」),放进已有文件夹,并把歌手、 专辑歌手标签统一成该写法,避免同一歌手被拆成两个目录 / 两个艺人。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
// 输出目录与文件名规则
|
// 输出目录与文件名规则
|
||||||
// album: {音乐目录}/{第一位专辑艺人}/{专辑}/{NN}. {歌名}.ext —— 与 NAS 现有曲库结构一致
|
// album: {音乐目录}/{第一位专辑艺人}/{专辑}/{NN}. {歌名}.ext —— 与 NAS 现有曲库结构一致
|
||||||
// flat : {音乐目录}/{歌手} - {歌名}.ext —— 旧版单曲下载格式
|
// flat : {音乐目录}/{歌手} - {歌名}.ext —— 旧版单曲下载格式
|
||||||
|
// 曲库里已有只差空格 / 大小写 / 全半角的歌手或专辑文件夹时,沿用已有文件夹名,避免同一歌手拆成两个目录
|
||||||
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { Layout } from '../config.ts';
|
import type { Layout } from '../config.ts';
|
||||||
import { sanitize, sanitizePathSegment } from './util.ts';
|
import { sanitize, sanitizePathSegment } from './util.ts';
|
||||||
@@ -18,6 +20,26 @@ export interface Target {
|
|||||||
dir: string;
|
dir: string;
|
||||||
baseName: string; // 不含扩展名
|
baseName: string; // 不含扩展名
|
||||||
isAlbumDir: boolean;
|
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 {
|
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') {
|
if (layout === 'flat') {
|
||||||
return { dir: musicDir, baseName: sanitize(`${input.artists} - ${title}`), isAlbumDir: false };
|
return { dir: musicDir, baseName: sanitize(`${input.artists} - ${title}`), isAlbumDir: false };
|
||||||
}
|
}
|
||||||
const firstArtist = input.artists.split(' / ')[0];
|
const primary = input.albumArtistPrimary || input.artists.split(' / ')[0] || '';
|
||||||
const artistDir = sanitizePathSegment(input.albumArtistPrimary || firstArtist, '未知歌手');
|
const wantArtistDir = sanitizePathSegment(primary, '未知歌手');
|
||||||
const albumDir = sanitizePathSegment(input.album, '未知专辑');
|
const artistDir = existingFolder(musicDir, wantArtistDir);
|
||||||
|
const albumDir = existingFolder(path.join(musicDir, artistDir), sanitizePathSegment(input.album, '未知专辑'));
|
||||||
let baseName = title;
|
let baseName = title;
|
||||||
if (input.trackNo) {
|
if (input.trackNo) {
|
||||||
const width = Math.max(2, String(input.trackTotal || 0).length);
|
const width = Math.max(2, String(input.trackTotal || 0).length);
|
||||||
baseName = `${String(input.trackNo).padStart(width, '0')}. ${title}`;
|
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(' / ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { Layout } from '../config.ts';
|
import type { Layout } from '../config.ts';
|
||||||
import { resolveTarget } from './layout.ts';
|
import { applyArtistAlias, resolveTarget } from './layout.ts';
|
||||||
import { mergeLrc, parseCredits } from './lyrics.ts';
|
import { mergeLrc, parseCredits } from './lyrics.ts';
|
||||||
import { downloadToFile, fetchBuffer, type Progress } from './net/http.ts';
|
import { downloadToFile, fetchBuffer, type Progress } from './net/http.ts';
|
||||||
import type { QualityKey } from './quality.ts';
|
import type { QualityKey } from './quality.ts';
|
||||||
@@ -100,7 +100,7 @@ async function downloadOnce(provider: MusicProvider, id: string, opts: DownloadO
|
|||||||
reporter?.stage?.('metadata', '获取元数据');
|
reporter?.stage?.('metadata', '获取元数据');
|
||||||
const meta = await provider.getTrackMeta(id).catch(() => null);
|
const meta = await provider.getTrackMeta(id).catch(() => null);
|
||||||
const name = audio.name || meta?.name || id;
|
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 album = audio.album || meta?.album || '';
|
||||||
|
|
||||||
const target = resolveTarget(ctx.musicDir, opts.layout, {
|
const target = resolveTarget(ctx.musicDir, opts.layout, {
|
||||||
@@ -111,6 +111,15 @@ async function downloadOnce(provider: MusicProvider, id: string, opts: DownloadO
|
|||||||
trackNo: meta?.trackNo,
|
trackNo: meta?.trackNo,
|
||||||
trackTotal: meta?.trackTotal,
|
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 });
|
await fs.promises.mkdir(target.dir, { recursive: true });
|
||||||
const dest = path.join(target.dir, `${target.baseName}.${audio.ext}`);
|
const dest = path.join(target.dir, `${target.baseName}.${audio.ext}`);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user