Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/en/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<amll:meta>`, 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
Expand Down
10 changes: 6 additions & 4 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的 `<amll:meta>`、LRC / LyS 的 `[ti:]` `[ar:]`),或按文件名中的**歌曲 ID**(命名为 `歌曲ID.后缀名` 或 `任意前缀.歌曲ID.后缀名`,ID 为纯数字;QQ 音乐的数字 songId 同样适用);
- 同 ID 或同名有多个文件时取**歌词格式优先级**最高的,多个目录则靠前的目录优先;
- **命中本地歌词时优先于在线歌词**,方便用你自己的版本覆盖在线结果。

### 歌词窗口
Expand Down
13 changes: 7 additions & 6 deletions electron/main/ipc/lyrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -41,6 +41,7 @@ import type {
LyricManualMatch,
LyricMatchResponse,
LyricMatchSource,
LyricRepoResponse,
LyricSearchResponse,
LyricTTMLResponse,
} from "@shared/types/lyrics";
Expand Down Expand Up @@ -266,19 +267,19 @@ export const registerLyricsIpc = (): void => {
}),
);
ipcMain.handle(
"lyrics:matchLocalTTML",
async (_evt, track: Track): Promise<LyricTTMLResponse> => {
"lyrics:matchLocalLyric",
async (_evt, track: Track): Promise<LyricRepoResponse> => {
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<string | null> => {
const result = await dialog.showOpenDialog({
title: "选择本地 TTML 歌词库目录",
title: "选择本地歌词库目录",
properties: ["openDirectory"],
});
if (result.canceled || result.filePaths.length === 0) return null;
Expand Down
190 changes: 190 additions & 0 deletions electron/main/services/localLyricRepo.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): Track =>
({
id: "1",
title: "未收录的歌",
artists: [{ name: "未知歌手" }],
source: "netease",
...patch,
}) as Track;

const tempDirs: string[] = [];

const makeDir = async (): Promise<string> => {
const dir = await mkdtemp(join(tmpdir(), "local-lyric-"));
tempDirs.push(dir);
return dir;
};

const write = async (file: string, content: string): Promise<void> => {
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>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 = [
"<ttml><head><metadata>",
'<amll:meta key="musicName" value="夜曲"/>',
'<amll:meta key="ncmMusicId" value="777"/>',
"</metadata></head><body></body></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 = [
"<ttml><head><metadata>",
'<amll:meta key="musicName" value="夜曲"/>',
'<amll:meta key="ncmMusicId" value="601"/>',
'<amll:meta key="ncmMusicId" value="602"/>',
"</metadata></head><body></body></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("缓存回查命中");
});
});
Loading