feat: 曲库索引 —— 曲库里已有的同名歌曲在列表中标记「曲库」
不是通过声匣下载的文件(其他工具 / 手动放入)以前一律显示未下载。 现在启动时扫描音乐目录,按「歌名 + 歌手文件夹 / 专辑文件夹」匹配, 下载完成增量更新,每 30 分钟或删除 / 补全标签后重建;设置页可手动重建。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
// 曲库索引:扫描音乐目录,按「歌名 + 歌手文件夹 / 专辑文件夹」判断某首歌是否已在曲库中,
|
||||
// 用于给不是通过本服务下载的文件也打上标记。
|
||||
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<string>;
|
||||
album: string;
|
||||
file: string; // 相对音乐目录
|
||||
}
|
||||
|
||||
/** 比较用的规范化:全角转半角、小写、去掉空白与引号类符号 */
|
||||
export function norm(s: string): string {
|
||||
return s
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[\s'"’‘“”`·・]/g, '');
|
||||
}
|
||||
|
||||
let index = new Map<string, Entry[]>();
|
||||
let building: Promise<void> | null = null;
|
||||
let builtAt = 0;
|
||||
let fileCount = 0;
|
||||
|
||||
function addFile(map: Map<string, Entry[]>, 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<string>();
|
||||
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<string, Entry[]>) {
|
||||
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<void> {
|
||||
building ??= (async () => {
|
||||
const started = Date.now();
|
||||
const map = new Map<string, Entry[]>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user