From 9f908fa73fcb945e95b445cecbdb69173bd0eb95 Mon Sep 17 00:00:00 2001 From: Jocay <1579649885@qq.com> Date: Sat, 10 Oct 2026 23:29:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=8B=E8=BD=BD=E6=B2=BF=E7=94=A8?= =?UTF-8?q?=E6=9B=B2=E5=BA=93=E5=B7=B2=E6=9C=89=E7=9A=84=E6=AD=8C=E6=89=8B?= =?UTF-8?q?=20/=20=E4=B8=93=E8=BE=91=E6=96=87=E4=BB=B6=E5=A4=B9=E5=86=99?= =?UTF-8?q?=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 歌手或专辑文件夹名与曲库已有文件夹只差空格、大小写、全半角时(如 「G.E.M.邓紫棋」与「G.E.M. 邓紫棋」),放进已有文件夹,并把歌手、 专辑歌手标签统一成该写法,避免同一歌手被拆成两个目录 / 两个艺人。 Co-Authored-By: Claude Opus 5.5 --- server/src/core/layout.ts | 46 +++++++++++++++++++++++++++++++++---- server/src/core/pipeline.ts | 13 +++++++++-- 2 files changed, 53 insertions(+), 6 deletions(-) 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}`);