// 曲库索引:扫描音乐目录,按「歌名 + 歌手文件夹 / 专辑文件夹」判断某首歌是否已在曲库中, // 用于给不是通过本服务下载的文件也打上标记。 import fs from 'node:fs'; import path from 'node:path'; import { config } from './config.ts'; import { sanitize, sanitizePathSegment } from './core/util.ts'; const AUDIO_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aac', '.ape', '.opus']); const REBUILD_INTERVAL = 30 * 60 * 1000; interface Entry { artists: Set; album: string; file: string; // 相对音乐目录 } /** 比较用的规范化:全角转半角、小写、去掉空白与引号类符号 */ export function norm(s: string): string { return s .normalize('NFKC') .toLowerCase() .replace(/[\s'"’‘“”`·・]/g, ''); } let index = new Map(); let building: Promise | null = null; let builtAt = 0; let fileCount = 0; function addFile(map: Map, rel: string) { const parts = rel.split('/'); const fileName = parts.at(-1)!; const base = fileName.slice(0, fileName.length - path.extname(fileName).length); const artists = new Set(); let title = base.replace(/^\d+[.\s_-]+\s*/, ''); // 「歌手 - 歌名」(旧版扁平格式) const dash = base.match(/^(.+?)\s+-\s+(.+)$/); if (dash && !/^\d+[.\s]/.test(base)) { title = dash[2]!; for (const a of dash[1]!.split(/\s*[/_&,,、]\s*/)) if (a) artists.add(norm(a)); } // 「歌手/专辑/NN. 歌名」:第一层目录是歌手(专辑艺人) if (parts.length >= 2) artists.add(norm(parts[0]!)); const album = parts.length >= 3 ? norm(parts.at(-2)!) : ''; const key = norm(title); if (!key) return; const list = map.get(key) ?? []; list.push({ artists, album, file: rel }); map.set(key, list); } async function walk(dir: string, rel: string, map: Map) { const entries = await fs.promises.readdir(dir, { withFileTypes: true }).catch(() => []); for (const e of entries) { if (e.name.startsWith('.') || e.name.startsWith('@')) continue; const childRel = rel ? `${rel}/${e.name}` : e.name; if (e.isDirectory()) await walk(path.join(dir, e.name), childRel, map); else if (AUDIO_EXTS.has(path.extname(e.name).toLowerCase())) { addFile(map, childRel); fileCount++; } } } export function rebuildLibraryIndex(): Promise { building ??= (async () => { const started = Date.now(); const map = new Map(); fileCount = 0; await walk(config.musicDir, '', map); index = map; builtAt = Date.now(); console.log(`📚 曲库索引:${fileCount} 个音频文件,用时 ${Date.now() - started} ms`); })().finally(() => (building = null)); return building; } /** 下载完成后增量加入,不必等下次重建 */ export function addToLibraryIndex(relFile: string) { addFile(index, relFile.split(path.sep).join('/')); } export function startLibraryIndex() { void rebuildLibraryIndex(); setInterval(() => void rebuildLibraryIndex(), REBUILD_INTERVAL).unref(); } export function libraryIndexInfo() { return { files: fileCount, builtAt: builtAt ? new Date(builtAt).toISOString() : null, building: !!building }; } /** * 在曲库中查找同名歌曲:歌名一致,且歌手文件夹与任一歌手一致或专辑文件夹与专辑一致。 * 返回匹配到的文件(相对路径),找不到返回 undefined。 */ export function findInLibrary(track: { name: string; artists: string; album?: string }): string | undefined { // 下载时文件名经过 sanitize,这里同样处理,"A/B" 才能对上 "A_B" const candidates = index.get(norm(sanitize(track.name))); if (!candidates) return undefined; const artists = track.artists .split(/\s*\/\s*/) .filter(Boolean) .map((a) => norm(sanitizePathSegment(a, a))); const album = track.album ? norm(sanitizePathSegment(track.album, track.album)) : ''; return candidates.find((c) => artists.some((a) => c.artists.has(a)) || (album && c.album === album))?.file; }