feat: 曲库索引 —— 曲库里已有的同名歌曲在列表中标记「曲库」

不是通过声匣下载的文件(其他工具 / 手动放入)以前一律显示未下载。
现在启动时扫描音乐目录,按「歌名 + 歌手文件夹 / 专辑文件夹」匹配,
下载完成增量更新,每 30 分钟或删除 / 补全标签后重建;设置页可手动重建。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jocay
2026-10-10 22:53:04 +08:00
parent d5152080bb
commit 85b2d36b3d
8 changed files with 181 additions and 10 deletions
+3
View File
@@ -12,6 +12,7 @@ import { embedTags } from './core/tagging.ts';
import type { Source, TrackSummary } from './core/types.ts';
import { errorMessage, sleep } from './core/util.ts';
import { bus } from './events.ts';
import { rebuildLibraryIndex } from './libraryIndex.ts';
export const AUDIO_EXTS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav', '.aac', '.ape', '.opus']);
const TRASH_DIR = path.join(config.dataDir, '.trash');
@@ -139,6 +140,7 @@ export async function moveToTrash(rel: string): Promise<string> {
await fs.promises.cp(target, dest, { recursive: true });
await fs.promises.rm(target, { recursive: true, force: true });
}
void rebuildLibraryIndex();
return toRel(target);
}
@@ -257,6 +259,7 @@ export async function startRetag(rel: string, source: Source, onlyMissingLyric:
await sleep(400);
}
current.finished = true;
void rebuildLibraryIndex();
current.current = undefined;
bus.emit('retag', current);
})();
+108
View File
@@ -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;
}
+2
View File
@@ -4,6 +4,7 @@ import { hasFFmpeg } from './core/tagging.ts';
import { importQQCookieFile, startQQKeeper } from './accounts.ts';
import { qq } from './core/providers/index.ts';
import { importLegacyHistory } from './db.ts';
import { startLibraryIndex } from './libraryIndex.ts';
import { queue } from './queue.ts';
const imported = importLegacyHistory(config.legacyHistoryFile);
@@ -16,5 +17,6 @@ createApp().listen(config.port, process.env.HOST || '0.0.0.0', async () => {
console.log(` 数据目录: ${config.dataDir}`);
queue.pump();
startQQKeeper(qq);
startLibraryIndex();
console.log(` ffmpeg : ${(await hasFFmpeg()) ? '✓' : '✗(FLAC 标签写入不可用)'}`);
});
+2
View File
@@ -6,6 +6,7 @@ import { isQualityKey, type QualityKey } from './core/quality.ts';
import { FatalError, type Source } from './core/types.ts';
import { errorMessage, sleep } from './core/util.ts';
import { addHistory, db, getSetting, setSetting } from './db.ts';
import { addToLibraryIndex } from './libraryIndex.ts';
import { bus } from './events.ts';
import { getSettings } from './settings.ts';
@@ -291,6 +292,7 @@ class DownloadQueue {
size: result.size,
file: result.relFile,
});
addToLibraryIndex(result.relFile);
bus.emit('history');
} catch (e) {
const reason = controller.signal.aborted ? (controller.signal.reason as AbortReason) : null;
+20 -3
View File
@@ -24,6 +24,7 @@ import {
writeM3u,
zipStream,
} from '../library.ts';
import { findInLibrary, libraryIndexInfo, rebuildLibraryIndex } from '../libraryIndex.ts';
import { queue, type EnqueueItem } from '../queue.ts';
import { getSettings, updateSettings } from '../settings.ts';
import { proxyStream } from '../stream.ts';
@@ -44,9 +45,16 @@ function body(ctx: Context): Record<string, any> {
}
/** 给列表中的歌曲标记是否已下载过 */
/** 给列表中的歌曲标记:下载历史里有(downloaded),或曲库里有同名文件(inLibrary) */
function markDownloaded<T extends { tracks: TrackSummary[] }>(source: string, detail: T) {
const owned = downloadedIds(source, detail.tracks.map((t) => t.id));
return { ...detail, tracks: detail.tracks.map((t) => ({ ...t, downloaded: owned.has(t.id) })) };
return {
...detail,
tracks: detail.tracks.map((t) => {
const downloaded = owned.has(t.id);
return { ...t, downloaded, inLibrary: downloaded ? undefined : findInLibrary(t) };
}),
};
}
// ---------- 基础 ----------
@@ -144,8 +152,8 @@ api.get('/resolve-link', async (ctx) => {
api.get('/:source/track/:id', async (ctx) => {
const provider = getProvider(ctx.params.source!);
const meta = (await provider.getTrackMeta(ctx.params.id!)) ?? notFound('歌曲');
const owned = downloadedIds(provider.source, [meta.id]);
ctx.body = { ...meta, downloaded: owned.has(meta.id) };
const downloaded = downloadedIds(provider.source, [meta.id]).has(meta.id);
ctx.body = { ...meta, downloaded, inLibrary: downloaded ? undefined : findInLibrary(meta) };
});
api.get('/:source/album/:id', async (ctx) => {
@@ -337,6 +345,15 @@ api.post('/library/trash', async (ctx) => {
ctx.body = { moved: await moveToTrash(String(body(ctx).path ?? '')) };
});
api.get('/library/index', (ctx) => {
ctx.body = libraryIndexInfo();
});
api.post('/library/index', async (ctx) => {
await rebuildLibraryIndex();
ctx.body = libraryIndexInfo();
});
api.get('/library/trash', async (ctx) => {
ctx.body = await trashInfo();
});