diff --git a/server/src/core/layout.ts b/server/src/core/layout.ts index 872b4af..66d3774 100644 --- a/server/src/core/layout.ts +++ b/server/src/core/layout.ts @@ -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(' / '); } diff --git a/server/src/core/pipeline.ts b/server/src/core/pipeline.ts index 3339213..762bc1c 100644 --- a/server/src/core/pipeline.ts +++ b/server/src/core/pipeline.ts @@ -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}`);