diff --git a/docs/en/guide.md b/docs/en/guide.md index b52e0a5b0..39986f38c 100644 --- a/docs/en/guide.md +++ b/docs/en/guide.md @@ -30,12 +30,14 @@ SPlayer-Next supports LRC, QRC, YRC, and TTML, including word-by-word highlighti - Configure the AMLL TTML DB URL template. It must contain `%p` for platform and `%s` for song ID. - When a match is found, its timed words, translation, and romanization are used. -### Local TTML library +### Local lyrics library -If you maintain your own TTML files, select a local directory as a lyrics library: +If you maintain your own lyric files, register one or more local folders as a lyrics library: -- Enable **Settings → Lyrics → Local TTML lyrics library** (Beta). -- Choose a folder containing `.ttml` files. Tracks are matched by metadata such as title and artist. +- Enable **Settings → Lyrics → Local lyrics library** (Beta). +- Add folders containing lyric files. `.ttml`, `.lrc`, `.lys`, `.ass`, and `.srt` are supported, and folders are scanned recursively. +- Tracks are matched by metadata (TTML ``, LRC / LyS `[ti:]` / `[ar:]`) or by the numeric song ID in the file name (`songID.ext` or `any-prefix.songID.ext`; the numeric QQ Music song ID works too). +- When several files match, the one with the highest lyric format priority wins; earlier folders win over later ones. - A local match takes precedence over online lyrics. ### Lyrics windows diff --git a/docs/guide.md b/docs/guide.md index fcab6bae1..e5d643895 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -32,12 +32,14 @@ SPlayer-Next 支持 LRC / QRC / YRC / TTML 多种歌词格式,提供逐字高 - 开启后可配置 **AMLL TTML DB 地址**。地址是一个模板,需包含两个占位符:`%p`(平台)与 `%s`(歌曲 ID),应用据此拼接请求;地址错误会导致获取失败; - 播放时会尝试从该数据库匹配 TTML,命中则使用其逐字 / 翻译 / 音译内容。 -### 本地 TTML 歌词库 +### 本地歌词库 -如果你自己收藏或校对了 TTML 歌词文件,可以指定一个本地目录作为歌词库: +如果你自己收藏或校对了歌词文件,可以指定多个本地目录作为歌词库: -- 在 **设置 → 歌词 → 启用本地 TTML 歌词库**(Beta)开启; -- 选择一个存放 `.ttml` 文件的目录,应用会按歌曲**元信息**(曲名 / 艺术家等)在其中查找; +- 在 **设置 → 歌词 → 启用本地歌词库**(Beta)开启; +- 添加一个或多个存放歌词文件的目录,支持 `.ttml`、`.lrc`、`.lys`、`.ass`、`.srt`,应用会**递归**查找其中的文件; +- 匹配方式有两种:按歌曲**元信息**(TTML 的 ``、LRC / LyS 的 `[ti:]` `[ar:]`),或按文件名中的**歌曲 ID**(命名为 `歌曲ID.后缀名` 或 `任意前缀.歌曲ID.后缀名`,ID 为纯数字;QQ 音乐的数字 songId 同样适用); +- 同 ID 或同名有多个文件时取**歌词格式优先级**最高的,多个目录则靠前的目录优先; - **命中本地歌词时优先于在线歌词**,方便用你自己的版本覆盖在线结果。 ### 歌词窗口 diff --git a/electron/main/ipc/lyrics.ts b/electron/main/ipc/lyrics.ts index c293c2295..7459460c2 100644 --- a/electron/main/ipc/lyrics.ts +++ b/electron/main/ipc/lyrics.ts @@ -28,7 +28,7 @@ import { pickBestCandidate, type LyricCandidate, } from "@main/apis/common/lyric/utils"; -import { matchLocalTTML } from "@main/services/localLyricRepo"; +import { matchLocalLyric } from "@main/services/localLyricRepo"; import { buildFingerprint, deleteMatchedId, @@ -41,6 +41,7 @@ import type { LyricManualMatch, LyricMatchResponse, LyricMatchSource, + LyricRepoResponse, LyricSearchResponse, LyricTTMLResponse, } from "@shared/types/lyrics"; @@ -266,19 +267,19 @@ export const registerLyricsIpc = (): void => { }), ); ipcMain.handle( - "lyrics:matchLocalTTML", - async (_evt, track: Track): Promise => { + "lyrics:matchLocalLyric", + async (_evt, track: Track): Promise => { try { - return { ok: true, data: await matchLocalTTML(track) }; + return { ok: true, data: await matchLocalLyric(track) }; } catch (err) { - coreLog.warn(`[lyrics] matchLocalTTML(${track.title}) failed:`, err); + coreLog.warn(`[lyrics] matchLocalLyric(${track.title}) failed:`, err); return { ok: false, error: err instanceof Error ? err.message : String(err) }; } }, ); ipcMain.handle("lyrics:pickLyricRepoDir", async (): Promise => { const result = await dialog.showOpenDialog({ - title: "选择本地 TTML 歌词库目录", + title: "选择本地歌词库目录", properties: ["openDirectory"], }); if (result.canceled || result.filePaths.length === 0) return null; diff --git a/electron/main/services/localLyricRepo.spec.ts b/electron/main/services/localLyricRepo.spec.ts new file mode 100644 index 000000000..5c623a1c1 --- /dev/null +++ b/electron/main/services/localLyricRepo.spec.ts @@ -0,0 +1,190 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Track } from "@shared/types/player"; + +const mocks = vi.hoisted(() => ({ + enabled: true, + dirs: [] as string[], + matched: null as { platformId: string; extra?: { mid?: string } } | null, +})); + +vi.mock("@main/store", () => ({ + store: { + get: (keyPath: string) => { + if (keyPath === "localLyric.enableLocalLyricOverride") return mocks.enabled; + if (keyPath === "localLyric.repoDirs") return mocks.dirs; + return undefined; + }, + }, +})); +vi.mock("@main/utils/logger", () => ({ coreLog: { info: () => {}, warn: () => {} } })); +vi.mock("@main/database/lyricMatchCache", () => ({ + buildFingerprint: () => "fingerprint", + getMatchedId: () => mocks.matched, +})); + +import { matchLocalLyric } from "./localLyricRepo"; + +const track = (patch: Partial = {}): Track => + ({ + id: "1", + title: "未收录的歌", + artists: [{ name: "未知歌手" }], + source: "netease", + ...patch, + }) as Track; + +const tempDirs: string[] = []; + +const makeDir = async (): Promise => { + const dir = await mkdtemp(join(tmpdir(), "local-lyric-")); + tempDirs.push(dir); + return dir; +}; + +const write = async (file: string, content: string): Promise => { + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, content, "utf-8"); +}; + +describe("本地歌词库匹配", () => { + beforeEach(() => { + mocks.enabled = true; + mocks.dirs = []; + mocks.matched = null; + }); + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + it("递归子目录里按文件名中的歌曲 ID 命中", async () => { + const dir = await makeDir(); + await write(join(dir, "收藏", "任意前缀.123.lrc"), "[00:01.00]本地歌词"); + mocks.dirs = [dir]; + await expect(matchLocalLyric(track({ id: "123" }))).resolves.toContain("本地歌词"); + }); + + it("同 ID 多格式时取格式优先级最高的 TTML", async () => { + const dir = await makeDir(); + await write(join(dir, "123.lrc"), "[00:01.00]lrc 歌词"); + await write(join(dir, "123.ttml"), "ttml 歌词"); + mocks.dirs = [dir]; + await expect(matchLocalLyric(track({ id: "123" }))).resolves.toContain("ttml 歌词"); + }); + + it("LRC 的 [ti:] / [ar:] 标签参与标题匹配", async () => { + const dir = await makeDir(); + await write(join(dir, "自校歌词.lrc"), "[ti:夜曲]\n[ar:周杰伦]\n[00:01.00]本地歌词"); + mocks.dirs = [dir]; + const target = track({ id: "9", title: "夜曲", artists: [{ name: "周杰伦" }] }); + await expect(matchLocalLyric(target)).resolves.toContain("本地歌词"); + }); + + it("TTML 的 AMLL 元信息参与匹配", async () => { + const dir = await makeDir(); + const ttml = [ + "", + '', + '', + "", + ].join(""); + await write(join(dir, "自校合集.ttml"), ttml); + mocks.dirs = [dir]; + await expect(matchLocalLyric(track({ id: "777", title: "别的名字" }))).resolves.toContain( + "ncmMusicId", + ); + }); + + it("TTML 里多个 ncmMusicId 都会进索引", async () => { + const dir = await makeDir(); + const ttml = [ + "", + '', + '', + '', + "", + ].join(""); + await write(join(dir, "自校合集.ttml"), ttml); + mocks.dirs = [dir]; + await expect(matchLocalLyric(track({ id: "602", title: "别的名字" }))).resolves.toContain( + "ncmMusicId", + ); + }); + + it("多个目录时靠前的目录优先", async () => { + const first = await makeDir(); + const second = await makeDir(); + await write(join(first, "8.lrc"), "[00:01.00]第一目录"); + await write(join(second, "8.lrc"), "[00:01.00]第二目录"); + mocks.dirs = [first, second]; + await expect(matchLocalLyric(track({ id: "8" }))).resolves.toContain("第一目录"); + }); + + it("开关关闭时不读取歌词文件", async () => { + const dir = await makeDir(); + await write(join(dir, "5.lrc"), "[00:01.00]本地歌词"); + mocks.dirs = [dir]; + mocks.enabled = false; + await expect(matchLocalLyric(track({ id: "5" }))).resolves.toBeNull(); + }); + + it("根目录新增文件后重建索引", async () => { + const dir = await makeDir(); + mocks.dirs = [dir]; + await expect(matchLocalLyric(track({ id: "42" }))).resolves.toBeNull(); + await write(join(dir, "42.lrc"), "[00:01.00]后来添加的"); + await expect(matchLocalLyric(track({ id: "42" }))).resolves.toContain("后来添加的"); + }); + + it("子目录新增文件后重建索引", async () => { + const dir = await makeDir(); + const sub = join(dir, "收藏"); + await mkdir(sub, { recursive: true }); + mocks.dirs = [dir]; + await expect(matchLocalLyric(track({ id: "42" }))).resolves.toBeNull(); + // 只写入子目录,父目录 mtime 不变,靠目录树签名发现变化 + await write(join(sub, "42.lrc"), "[00:01.00]子目录后来添加的"); + await expect(matchLocalLyric(track({ id: "42" }))).resolves.toContain("子目录后来添加的"); + }); + + it("构建途中更换目录集合时按新集合返回", async () => { + const first = await makeDir(); + const second = await makeDir(); + await write(join(second, "66.lrc"), "[00:01.00]第二目录"); + mocks.dirs = [first]; + const stale = matchLocalLyric(track({ id: "66" })); + mocks.dirs = [second]; + const fresh = matchLocalLyric(track({ id: "66" })); + await expect(fresh).resolves.toContain("第二目录"); + await expect(stale).resolves.toBeNull(); + }); + + it("QQ 的 songmid 不参与文件名匹配,数字 songId 仍可命中", async () => { + const dir = await makeDir(); + await write(join(dir, "0039MnYb0qxYhV.lrc"), "[00:01.00]mid 命名"); + await write(join(dir, "7531681.lrc"), "[00:01.00]数字命名"); + mocks.dirs = [dir]; + // QQ 音乐 Track 的 id 是 songmid、extId 是数字 songId + const byMid = track({ id: "0039MnYb0qxYhV", source: "qqmusic", title: "无题", artists: [] }); + await expect(matchLocalLyric(byMid)).resolves.toBeNull(); + const byNumeric = track({ + id: "0039MnYb0qxYhV", + extId: "7531681", + source: "qqmusic", + title: "无题", + artists: [], + }); + await expect(matchLocalLyric(byNumeric)).resolves.toContain("数字命名"); + }); + + it("平台 ID 与标题都没命中时用匹配缓存回查", async () => { + const dir = await makeDir(); + await write(join(dir, "314.lrc"), "[00:01.00]缓存回查命中"); + mocks.dirs = [dir]; + mocks.matched = { platformId: "314" }; + await expect(matchLocalLyric(track({ id: "0" }))).resolves.toContain("缓存回查命中"); + }); +}); diff --git a/electron/main/services/localLyricRepo.ts b/electron/main/services/localLyricRepo.ts index 24ca9363a..e1649e5c0 100644 --- a/electron/main/services/localLyricRepo.ts +++ b/electron/main/services/localLyricRepo.ts @@ -1,25 +1,37 @@ /** - * 本地 TTML 歌词库 + * 本地歌词库 * - * 用户指定一个目录作为 TTML 歌词仓库。按需扫描目录下所有 .ttml,解析 AMLL - * `` 头(musicName / artists / ncmMusicId / qqMusicId)建立索引: - * - 平台 id(NCM / QQ)→ 文件路径(精确命中) - * - 归一化标题 → 候选列表 - * 命中返回文件原文,交由渲染层 parseTTML 解析。 + * 用户指定若干目录作为歌词仓库。按需递归扫描其中的 .ttml / .lrc / .lys / .ass / .srt, + * 解析元信息建立索引: + * - 文件名里的歌曲 ID(`歌曲ID.后缀名` 或 `任意前缀.歌曲ID.后缀名`)→ 候选文件 + * - TTML 的 AMLL `` 头(ncmMusicId / qqMusicId,可多个)→ 候选文件 + * - 标题(TTML 的 musicName、LRC / LyS 的 [ti:])→ 同名候选 + * 命中时返回文件原文,交由渲染层按实际格式解析。 * - * 索引以(目录, 目录 mtime)为缓存边界:目录或其 mtime 变化(增删文件)时重建。 - * 仅保留路径与小键,不驻留文件内容;命中时按需读盘。 + * 索引以(目录集合, 整棵目录树的 mtime)为缓存边界:目录列表或任一目录(含子目录) + * 变化(增删文件)时重建。仅保留路径与小键,不驻留文件内容;命中时按需读盘。 */ import { readdir, stat } from "node:fs/promises"; -import { join, extname } from "node:path"; +import { basename, extname, join } from "node:path"; import { readFileAutoEncoding } from "@main/utils/encoding"; import { store } from "@main/store"; import { normalize } from "@main/apis/common/lyric/utils"; import { buildFingerprint, getMatchedId } from "@main/database/lyricMatchCache"; import { coreLog } from "@main/utils/logger"; +import { DEFAULT_LYRIC_FORMAT_ORDER } from "@shared/types/lyrics"; +import type { LyricFormat } from "lyric-kit"; import type { Track } from "@shared/types/player"; +/** 可识别的扩展名与其歌词格式 */ +const EXTENSION_FORMATS: Record = { + ".ttml": "ttml", + ".lrc": "lrc", + ".lys": "lys", + ".ass": "ass", + ".srt": "srt", +}; + /** 同名候选:艺术家仅作区分,不作硬门槛 */ interface NameCandidate { /** 归一化首艺术家,缺失为空串 */ @@ -28,20 +40,30 @@ interface NameCandidate { } interface RepoIndex { - byNcm: Map; - byQq: Map; + /** 文件名里的歌曲 ID → 候选文件 */ + byFileId: Map; + byNcm: Map; + byQq: Map; /** 归一化标题 → 同名候选 */ byTitle: Map; } interface IndexCache { - dir: string; - mtimeMs: number; + /** 目录集合(含顺序),顺序不同视为不同优先级 */ + dirsKey: string; + /** 目录签名,见 collectDirSignature */ + signature: number[]; index: RepoIndex; } +interface Building { + /** 发起构建时的目录集合 */ + dirsKey: string; + task: Promise; +} + let cache: IndexCache | null = null; -let building: Promise | null = null; +let building: Building | null = null; interface TTMLMeta { name?: string; @@ -51,7 +73,7 @@ interface TTMLMeta { } /** 从 TTML 文本头部提取 AMLL 元信息 */ -const extractMeta = (text: string): TTMLMeta => { +const extractTtmlMeta = (text: string): TTMLMeta => { const bodyAt = text.indexOf(" 0 ? text.slice(0, bodyAt) : text.slice(0, 8000); const meta: TTMLMeta = { ncmIds: [], qqIds: [] }; @@ -67,8 +89,75 @@ const extractMeta = (text: string): TTMLMeta => { return meta; }; -/** 递归收集目录下所有 .ttml 文件 */ -const collectTtml = async (dir: string): Promise => { +/** + * 取文件名里的歌曲 ID:`歌曲ID.后缀名` 或 `任意前缀.歌曲ID.后缀名` + * 只认纯数字的平台 ID(网易云的 songId、QQ 音乐的 songId);QQ 的 songmid 不在此列 + * @param file - 文件路径 + * @returns 全数字 ID,不符合命名规则时返回 null + */ +const idFromFileName = (file: string): string | null => { + const name = basename(file, extname(file)); + const segment = name.slice(name.lastIndexOf(".") + 1); + return /^\d+$/.test(segment) ? segment : null; +}; + +/** 文件在全局歌词格式优先级中的位置,未知格式排最后 */ +const formatRank = (file: string): number => { + const format = EXTENSION_FORMATS[extname(file).toLowerCase()]; + const rank = format ? DEFAULT_LYRIC_FORMAT_ORDER.indexOf(format) : -1; + return rank === -1 ? DEFAULT_LYRIC_FORMAT_ORDER.length : rank; +}; + +/** 同 ID / 同名多文件时取格式优先级最高的,同格式保持目录顺序 */ +const pickByFormat = (files: string[]): string | undefined => { + if (files.length === 0) return undefined; + return [...files].sort((a, b) => formatRank(a) - formatRank(b))[0]; +}; + +/** + * 同名候选里按艺术家挑出一组;只有一条或无法用艺术家区分时取全部 + * @param candidates - 同一标题下的候选 + * @param wantArtist - 归一化的目标首艺术家 + * @returns 命中的文件路径,未命中返回 undefined + */ +const pickByArtist = (candidates: NameCandidate[], wantArtist: string): string | undefined => { + if (candidates.length === 0) return undefined; + if (!wantArtist) return pickByFormat(candidates.map((candidate) => candidate.file)); + const exact = candidates.filter((candidate) => candidate.artist === wantArtist); + if (exact.length > 0) return pickByFormat(exact.map((candidate) => candidate.file)); + const partial = candidates.filter( + (candidate) => + candidate.artist && + (candidate.artist.includes(wantArtist) || wantArtist.includes(candidate.artist)), + ); + const pool = partial.length > 0 ? partial : candidates; + return pickByFormat(pool.map((candidate) => candidate.file)); +}; + +/** 头标签扫描范围:LRC / LyS 的元信息都在文件开头,只看开头即可,避免整篇解析 */ +const HEAD_SCAN_LENGTH = 4096; + +/** 与 lyric-kit 的 LRC 元信息标签规则一致:整行形如 [key:value] */ +const HEAD_TAG_RE = /^[ \t]*\[(ti|ar):(.*?)]\s*$/gim; + +/** + * 解析 LRC / LyS 的头标签,取 [ti:] 与 [ar:] + * @param text - 歌词原文 + * @returns 歌曲名与首艺术家,缺失时为空 + */ +const extractTaggedMeta = (text: string): { name?: string; artist?: string } => { + const meta: { name?: string; artist?: string } = {}; + for (const tag of text.slice(0, HEAD_SCAN_LENGTH).matchAll(HEAD_TAG_RE)) { + const value = tag[2].trim(); + if (!value) continue; + if (tag[1].toLowerCase() === "ti") meta.name = value; + else meta.artist = value; + } + return meta; +}; + +/** 递归收集目录下所有受支持的歌词文件 */ +const collectLyricFiles = async (dir: string): Promise => { const out: string[] = []; const walk = async (current: string): Promise => { let entries; @@ -77,67 +166,149 @@ const collectTtml = async (dir: string): Promise => { } catch { return; } - for (const entry of entries) { + for (const entry of entries.sort(byName)) { const full = join(current, entry.name); if (entry.isDirectory()) await walk(full); - else if (entry.isFile() && extname(entry.name).toLowerCase() === ".ttml") out.push(full); + else if (entry.isFile() && EXTENSION_FORMATS[extname(entry.name).toLowerCase()]) + out.push(full); } }; await walk(dir); return out; }; -/** 扫描目录建立索引 */ -const buildIndex = async (dir: string): Promise => { - const index: RepoIndex = { byNcm: new Map(), byQq: new Map(), byTitle: new Map() }; - const files = await collectTtml(dir); +/** 按名称排序,保证两次遍历顺序一致 */ +const byName = (a: { name: string }, b: { name: string }): number => + a.name < b.name ? -1 : a.name > b.name ? 1 : 0; + +/** + * 目录签名:递归取每个目录自身的 mtime,按固定顺序排列 + * + * 扫描是递归的,只盯着顶层目录会漏掉子目录里的增删(父目录 mtime 不变), + * 因此缓存边界必须覆盖整棵目录树;目录不存在时记 -1。 + * @param dirs - 歌词库目录 + * @returns 目录签名 + */ +const collectDirSignature = async (dirs: string[]): Promise => { + const signature: number[] = []; + const visit = async (dir: string): Promise => { + let mtime: number; + try { + mtime = (await stat(dir)).mtimeMs; + } catch { + // 目录不存在或读不到时只记 -1,不递归 + signature.push(-1); + return; + } + signature.push(mtime); + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries.sort(byName)) { + if (entry.isDirectory()) await visit(join(dir, entry.name)); + } + }; + for (const dir of dirs) await visit(dir); + return signature; +}; + +/** 追加候选文件,保留先扫到的顺序 */ +const pushFile = (map: Map, key: string, file: string): void => { + const list = map.get(key); + if (list) list.push(file); + else map.set(key, [file]); +}; + +/** 扫描所有目录建立索引 */ +const buildIndex = async (dirs: string[]): Promise => { + const index: RepoIndex = { + byFileId: new Map(), + byNcm: new Map(), + byQq: new Map(), + byTitle: new Map(), + }; + const files = (await Promise.all(dirs.map((dir) => collectLyricFiles(dir)))).flat(); for (const file of files) { + const format = EXTENSION_FORMATS[extname(file).toLowerCase()]; + if (!format) continue; + const fileId = idFromFileName(file); + if (fileId) pushFile(index.byFileId, fileId, file); + if (format === "ass" || format === "srt") continue; + let text: string; try { text = await readFileAutoEncoding(file); } catch { continue; } - const meta = extractMeta(text); - for (const id of meta.ncmIds) if (!index.byNcm.has(id)) index.byNcm.set(id, file); - for (const id of meta.qqIds) if (!index.byQq.has(id)) index.byQq.set(id, file); - if (meta.name) { - const titleKey = normalize(meta.name); - const candidate: NameCandidate = { artist: normalize(meta.artist ?? ""), file }; + let name: string | undefined; + let artist: string | undefined; + if (format === "ttml") { + const meta = extractTtmlMeta(text); + for (const id of meta.ncmIds) pushFile(index.byNcm, id, file); + for (const id of meta.qqIds) pushFile(index.byQq, id, file); + name = meta.name; + artist = meta.artist; + } else { + const meta = extractTaggedMeta(text); + name = meta.name; + artist = meta.artist; + } + if (name) { + const titleKey = normalize(name); + const candidate: NameCandidate = { artist: normalize(artist ?? ""), file }; const list = index.byTitle.get(titleKey); if (list) list.push(candidate); else index.byTitle.set(titleKey, [candidate]); } } - coreLog.info(`[localLyric] 索引完成:${files.length} 个文件 @ ${dir}`); + coreLog.info(`[localLyric] 索引完成:${files.length} 个文件 @ ${dirs.join(", ")}`); return index; }; -/** 取当前生效索引;目录或 mtime 变化时懒重建 */ +/** 当前配置的歌词库目录 */ +const getRepoDirs = (): string[] => { + const dirs = store.get("localLyric.repoDirs") ?? []; + return dirs.map((dir) => dir.trim()).filter((dir) => dir !== ""); +}; + +/** 目录签名是否一致 */ +const sameSignature = (a: number[], b: number[]): boolean => + a.length === b.length && a.every((mtime, index) => mtime === b[index]); + +/** 取当前生效索引;目录集合或整棵目录树(含子目录)的 mtime 变化时懒重建 */ const getIndex = async (): Promise => { - const dir = store.get("localLyric.repoDir") || ""; - if (!dir) return null; - let mtimeMs: number; - try { - mtimeMs = (await stat(dir)).mtimeMs; - } catch { - return null; + const dirs = getRepoDirs(); + if (dirs.length === 0) return null; + const dirsKey = dirs.join("\n"); + const signature = await collectDirSignature(dirs); + if (signature.every((mtime) => mtime < 0)) return null; + if (cache && cache.dirsKey === dirsKey && sameSignature(cache.signature, signature)) { + return cache.index; + } + if (building) { + // 同一批目录的构建直接复用;目录集合刚变过则等它收尾,再按当前集合重建 + if (building.dirsKey === dirsKey) return building.task; + await building.task; } - if (cache && cache.dir === dir && cache.mtimeMs === mtimeMs) return cache.index; - if (building) return building; - building = (async () => { + const pending: Building = { dirsKey, task: Promise.resolve(null) }; + pending.task = (async () => { try { - const index = await buildIndex(dir); - cache = { dir, mtimeMs, index }; + const index = await buildIndex(dirs); + cache = { dirsKey, signature, index }; return index; } catch (err) { coreLog.warn("[localLyric] 索引构建失败:", err); return null; } finally { - building = null; + if (building === pending) building = null; } })(); - return building; + building = pending; + return pending.task; }; /** 读取文件,失败返回 null */ @@ -150,75 +321,74 @@ const tryRead = async (file: string | undefined): Promise => { } }; -/** - * 同名候选里按艺术家挑最匹配的;只有一条或无法用艺术家区分时取第一条 - * @param candidates - 同一标题下的候选 - * @param wantArtist - 归一化的目标首艺术家 - * @returns 命中文件路径 - */ -const pickByArtist = (candidates: NameCandidate[], wantArtist: string): string => { - if (candidates.length === 1 || !wantArtist) return candidates[0].file; - const exact = candidates.find((candidate) => candidate.artist === wantArtist); - if (exact) return exact.file; - const partial = candidates.find( - (candidate) => - candidate.artist && - (candidate.artist.includes(wantArtist) || wantArtist.includes(candidate.artist)), - ); - return (partial ?? candidates[0]).file; +/** 按 ID 候选依次查表,取第一个有候选的 ID */ +const lookupFiles = (map: Map, ids: string[]): string | undefined => { + for (const id of ids) { + const files = map.get(id); + if (files && files.length > 0) return pickByFormat(files); + } + return undefined; }; +/** Track 自带的平台 ID 候选;QQ 的 extId 即 mid,优先于数字 id */ +const trackIds = (track: Track): string[] => { + const ids = track.source === "qqmusic" ? [track.extId, track.id] : [track.id, track.extId]; + return ids.filter((id): id is string => !!id); +}; + +/** 文件名 ID 只认纯数字,QQ mid 等非数字候选留给元信息表 */ +const numericIds = (ids: string[]): string[] => ids.filter((id) => /^\d+$/.test(id)); + /** * 用匹配缓存里在线模糊搜索解析出的平台 id 回查本地库 * 本地歌首播时没有平台 id、标题也可能对不上,靠在线搜索事后写入的 id 兜底命中 * @param track - 歌曲信息 * @param index - 当前索引 - * @returns 命中的 TTML 原文,未命中返回 null + * @returns 命中的歌词原文,未命中返回 null */ const matchByCachedId = async (track: Track, index: RepoIndex): Promise => { const fingerprint = buildFingerprint(track); const ncm = getMatchedId(fingerprint, "netease"); if (ncm) { - const hit = await tryRead(index.byNcm.get(ncm.platformId)); + const hit = await tryRead( + lookupFiles(index.byNcm, [ncm.platformId]) ?? lookupFiles(index.byFileId, [ncm.platformId]), + ); if (hit) return hit; } const qq = getMatchedId(fingerprint, "qqmusic"); if (qq) { - for (const idCandidate of [qq.extra?.mid, qq.platformId]) { - if (!idCandidate) continue; - const hit = await tryRead(index.byQq.get(idCandidate)); - if (hit) return hit; - } + const ids = [qq.extra?.mid, qq.platformId].filter((id): id is string => !!id); + const hit = await tryRead( + lookupFiles(index.byQq, ids) ?? lookupFiles(index.byFileId, numericIds(ids)), + ); + if (hit) return hit; } return null; }; /** - * 在本地 TTML 歌词库中匹配当前歌曲 + * 在本地歌词库中匹配当前歌曲 * @param track - 歌曲信息 - * @returns 命中的 TTML 原文,未命中返回 null + * @returns 命中的歌词文件原文,未命中返回 null */ -export const matchLocalTTML = async (track: Track): Promise => { - if (!store.get("localLyric.enableLocalTTMLOverride")) return null; +export const matchLocalLyric = async (track: Track): Promise => { + if (!store.get("localLyric.enableLocalLyricOverride")) return null; const index = await getIndex(); if (!index) return null; + const ids = trackIds(track); // track 自带平台 id 精确命中(在线歌曲) - if (track.source === "netease") { - const hit = await tryRead(index.byNcm.get(track.id)); - if (hit) return hit; - } - if (track.source === "qqmusic") { - for (const idCandidate of [track.extId, track.id]) { - if (!idCandidate) continue; - const hit = await tryRead(index.byQq.get(idCandidate)); - if (hit) return hit; - } - } + const byMetaId = + track.source === "netease" + ? lookupFiles(index.byNcm, ids) + : track.source === "qqmusic" + ? lookupFiles(index.byQq, ids) + : undefined; + const idHit = await tryRead(byMetaId ?? lookupFiles(index.byFileId, numericIds(ids))); + if (idHit) return idHit; // 标题命中;同名多条时再用艺术家软性区分 - const candidates = index.byTitle.get(normalize(track.title)); - if (candidates && candidates.length > 0) { - return tryRead(pickByArtist(candidates, normalize(track.artists[0]?.name ?? ""))); - } + const candidates = index.byTitle.get(normalize(track.title)) ?? []; + const titleHit = await tryRead(pickByArtist(candidates, normalize(track.artists[0]?.name ?? ""))); + if (titleHit) return titleHit; // 兜底:平台 id 回查 return matchByCachedId(track, index); }; diff --git a/electron/main/store/migrations.spec.ts b/electron/main/store/migrations.spec.ts new file mode 100644 index 000000000..3ad93c77a --- /dev/null +++ b/electron/main/store/migrations.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { defaultSystemConfig } from "@shared/defaults/settings"; +import type { SystemConfig } from "@shared/types/settings"; +import { migrations } from "./migrations"; + +/** 还原老版本 settings.json 经 deepMerge 后的形态:新键取默认值,另有单目录与旧开关 */ +const legacyConfig = (repoDir: string, enabled: boolean): SystemConfig => { + const data = structuredClone(defaultSystemConfig); + const localLyric = data.localLyric as unknown as Record; + localLyric.repoDir = repoDir; + localLyric.enableLocalTTMLOverride = enabled; + return data; +}; + +const migrateToLatest = (data: SystemConfig): void => { + for (const migration of migrations.filter((m) => m.version > 0)) migration.migrate(data); +}; + +describe("配置迁移", () => { + it("version 3 把单目录与旧开关搬到多目录新键", () => { + const data = legacyConfig("D:\\Lyrics", true); + migrateToLatest(data); + expect(data.localLyric.repoDirs).toEqual(["D:\\Lyrics"]); + expect(data.localLyric.enableLocalLyricOverride).toBe(true); + expect("repoDir" in data.localLyric).toBe(false); + expect("enableLocalTTMLOverride" in data.localLyric).toBe(false); + }); + + it("未设置过歌词库目录时迁移为空列表", () => { + const data = legacyConfig("", false); + migrateToLatest(data); + expect(data.localLyric.repoDirs).toEqual([]); + expect(data.localLyric.enableLocalLyricOverride).toBe(false); + expect("repoDir" in data.localLyric).toBe(false); + }); +}); diff --git a/electron/main/store/migrations.ts b/electron/main/store/migrations.ts index 3e1625553..1d276777e 100644 --- a/electron/main/store/migrations.ts +++ b/electron/main/store/migrations.ts @@ -28,4 +28,22 @@ export const migrations: Migration[] = [ } }, }, + { + // 本地 TTML 歌词库扩展为通用歌词库:单目录改为多目录,旧开关改名 + version: 3, + migrate: (data) => { + const localLyric = data.localLyric as unknown as { + repoDir?: string; + repoDirs?: string[]; + enableLocalTTMLOverride?: boolean; + enableLocalLyricOverride?: boolean; + }; + if (localLyric.repoDir) localLyric.repoDirs = [localLyric.repoDir]; + if (localLyric.enableLocalTTMLOverride !== undefined) { + localLyric.enableLocalLyricOverride = localLyric.enableLocalTTMLOverride; + } + delete localLyric.repoDir; + delete localLyric.enableLocalTTMLOverride; + }, + }, ]; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 56fb574e6..e759d6cf8 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -573,9 +573,9 @@ const api = { ipcRenderer.invoke("lyrics:fetchTTMLOverlay", track, platform), // 按元数据搜索 AMLL TTML DB searchTTML: (track: unknown) => ipcRenderer.invoke("lyrics:searchTTML", track), - // 在本地 TTML 歌词库目录中按元信息匹配 - matchLocalTTML: (track: unknown) => ipcRenderer.invoke("lyrics:matchLocalTTML", track), - // 选择本地 TTML 歌词库目录 + // 在本地歌词库目录中按元信息或歌曲 ID 匹配 + matchLocalLyric: (track: unknown) => ipcRenderer.invoke("lyrics:matchLocalLyric", track), + // 选择本地歌词库目录 pickLyricRepoDir: () => ipcRenderer.invoke("lyrics:pickLyricRepoDir"), }, opencc: { diff --git a/shared/defaults/settings.ts b/shared/defaults/settings.ts index 276e9f27b..48aa07f0a 100644 --- a/shared/defaults/settings.ts +++ b/shared/defaults/settings.ts @@ -100,8 +100,8 @@ export const defaultSystemConfig: SystemConfig = { amllDbServer: "", }, localLyric: { - enableLocalTTMLOverride: false, - repoDir: "", + enableLocalLyricOverride: false, + repoDirs: [], }, cache: { dir: null, diff --git a/shared/types/lyrics.ts b/shared/types/lyrics.ts index b6d7dee6e..07584b9cd 100644 --- a/shared/types/lyrics.ts +++ b/shared/types/lyrics.ts @@ -82,6 +82,9 @@ export type LyricSearchResponse = /** TTML 抓取 IPC 响应 */ export type LyricTTMLResponse = { ok: true; data: string | null } | { ok: false; error: string }; +/** 本地歌词库匹配 IPC 响应 */ +export type LyricRepoResponse = { ok: true; data: string | null } | { ok: false; error: string }; + /** 渲染端歌词匹配入口 */ export interface LyricsApi { /** 按 id 直取某平台歌词,KG 依赖 track 的歌名和时长,prefetch 为 false 时不预热 TTML */ @@ -115,8 +118,8 @@ export interface LyricsApi { fetchTTMLOverlay: (track: Track, platform: "netease" | "qqmusic") => Promise; /** 按 Track 元数据搜索 AMLL TTML DB,用于没有可用平台 ID 或 ID 未命中的歌曲 */ searchTTML: (track: Track) => Promise; - /** 在本地 TTML 歌词库中按元信息匹配,命中返回 TTML 原文 */ - matchLocalTTML: (track: Track) => Promise; - /** 弹出目录选择器,返回所选本地 TTML 歌词库目录 */ + /** 在本地歌词库中按元信息或歌曲 ID 匹配,命中返回歌词原文 */ + matchLocalLyric: (track: Track) => Promise; + /** 弹出目录选择器,返回所选本地歌词库目录 */ pickLyricRepoDir: () => Promise; } diff --git a/shared/types/settings.ts b/shared/types/settings.ts index d4a1b8ba9..d97f77381 100644 --- a/shared/types/settings.ts +++ b/shared/types/settings.ts @@ -321,10 +321,10 @@ export interface OnlineLyricSettings { /** 本地歌词配置 */ export interface LocalLyricSettings { - /** 启用本地 TTML 歌词库:从指定目录按元信息匹配 .ttml,命中优先于在线源 */ - enableLocalTTMLOverride: boolean; - /** 本地 TTML 歌词库目录 */ - repoDir: string; + /** 启用本地歌词库:从指定目录按元信息或文件名中的歌曲 ID 匹配,命中优先于在线源 */ + enableLocalLyricOverride: boolean; + /** 本地歌词库目录列表,靠前的目录优先 */ + repoDirs: string[]; } /** 歌曲缓存配置 */ diff --git a/src/components/settings/custom/LocalLyricRepoConfig.vue b/src/components/settings/custom/LocalLyricRepoConfig.vue index 7871db520..f1717f339 100644 --- a/src/components/settings/custom/LocalLyricRepoConfig.vue +++ b/src/components/settings/custom/LocalLyricRepoConfig.vue @@ -5,13 +5,15 @@ import IconLucideTrash2 from "~icons/lucide/trash-2"; defineOptions({ inheritAttrs: false }); -defineProps<{ modelValue: string }>(); -const emit = defineEmits<{ "update:modelValue": [value: string] }>(); +const props = defineProps<{ modelValue: string[] }>(); +const emit = defineEmits<{ "update:modelValue": [value: string[]] }>(); const { t } = useI18n(); const open = ref(false); +const dirs = computed(() => props.modelValue ?? []); + const folderName = (dir: string): string => { const parts = dir.replace(/\\/g, "/").split("/").filter(Boolean); return parts[parts.length - 1] || dir; @@ -19,10 +21,15 @@ const folderName = (dir: string): string => { const choose = async () => { const dir = await window.api.lyrics.pickLyricRepoDir(); - if (dir) emit("update:modelValue", dir); + if (!dir || dirs.value.includes(dir)) return; + emit("update:modelValue", [...dirs.value, dir]); }; -const clear = () => emit("update:modelValue", ""); +const remove = (dir: string) => + emit( + "update:modelValue", + dirs.value.filter((item) => item !== dir), + );