Skip to content

feat: 基本的歌词匹配#11

Merged
imsyy merged 15 commits intodevfrom
api-lyric
Apr 27, 2026
Merged

feat: 基本的歌词匹配#11
imsyy merged 15 commits intodevfrom
api-lyric

Conversation

@imsyy
Copy link
Copy Markdown
Collaborator

@imsyy imsyy commented Apr 26, 2026

No description provided.

Copilot AI review requested due to automatic review settings April 26, 2026 15:45
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

该 PR 为播放器引入“基本的歌词匹配”能力:在渲染端根据设置偏好选择本地/在线歌词源,并通过新增 IPC + 主进程缓存/去重机制,从 Netease/QQMusic/Kugou 拉取逐字歌词及翻译/音译,同时补齐多种逐字歌词解析器(QRC/YRC/KRC)与 TTML overlay 升级链路。

Changes:

  • 新增在线歌词匹配 IPC(byId/byQuery)与 AMLL TTML DB overlay 抓取,并在主进程增加多层缓存(接口返回缓存、fuzzy 映射缓存、TTML 缓存)。
  • 渲染端新增歌词加载服务(按“歌词来源偏好”决策),并将歌词解析入口升级为支持“主歌词 + 翻译/音译”载荷(LyricInput)。
  • 新增/调整多种歌词解析器(QRC/YRC/KRC)与 LRC 同时间戳翻译行合并逻辑;补充设置项与 UI 入口(全屏播放器内快速切换偏好)。

Reviewed changes

Copilot reviewed 43 out of 45 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/utils/lyric/parseYRC.ts 新增 YRC 逐字解析器
src/utils/lyric/parseTimeline.ts 删除原 QRC/YRC 通用 timeline 解析器(拆分为独立文件)
src/utils/lyric/parseQRC.ts 新增 QRC 逐字解析器(含 XML 包裹提取)
src/utils/lyric/parseLRC.ts 增加同 startTime 的翻译/音译行合并,并基于合并结果回填 endTime
src/utils/lyric/parseKRC.ts 新增 KRC 逐字解析器(offset+dur 相对行首)
src/utils/lyric/parse.ts parseLyric 入参升级为 LyricInput,并加入 krc 解析与格式优先级调整
src/types/settings.ts 新增 LyricSourcePreference 类型与 LyricSettings 字段
src/stores/settings.ts 为 lyric 设置增加 lyricSourcePreference 默认值
src/stores/media.ts 媒体 store 改为承载 LyricInput,提供 resetLyricState/setLyric 等原子接口
src/settings/schema.ts 设置页新增“歌词内容”区块与歌词来源偏好下拉、TTML 开关及配置入口
src/services/playback.ts setCurrentTime 增加容差同步逻辑并返回实际生效位置;setPlaying 行为调整
src/services/lyricLoader.ts 新增歌词加载服务:本地优先展示 + 按偏好在线匹配 + TTML overlay 热替换
src/pages/Home.vue 将 Home 页改为歌词匹配测试面板(平台切换+原始返回展示)
src/main.ts 启动时初始化 lyricLoader 的偏好 watcher
src/i18n/locales/zh-CN.json 增加歌词来源偏好/TTML/AMLL 配置相关文案
src/i18n/locales/en-US.json 同步增加英文文案
src/core/player.ts 切歌/加载时接入 lyricLoader;播放位置同步使用 setCurrentTime 返回值
src/components/ui/SPopselect.vue 新增封面主题弹出式 select 组件(reka-ui Select 封装)
src/components/settings/custom/AmllDbServerConfig.vue 新增 AMLL TTML DB 地址配置对话框
src/components/player/FullPlayer/PlayerData.vue 在全屏播放器信息区加入歌词来源偏好快速切换(SPopselect)
shared/types/settings.ts SystemConfig 增加在线歌词服务配置 OnlineLyricSettings
shared/types/lyrics.ts LyricFormat 增加 krc;新增 LyricInput/LyricsApi/匹配响应类型
shared/defaults/settings.ts 增加在线歌词服务默认配置(AMLL DB 模板 URL 等)
electron/preload/index.ts 暴露 window.api.lyrics(matchById/matchByQuery/fetchTTMLOverlay)
electron/preload/index.d.ts 为 window.api 增加 lyrics: LyricsApi 声明
electron/main/ipc/lyrics.ts 新增歌词匹配/TTML overlay IPC handlers + 并发去重
electron/main/ipc/index.ts 注册 lyrics IPC handlers
electron/main/database/migration.ts 迁移逻辑调整,补 lyric_match_cache.extra 列
electron/main/database/lyricTtmlCache.ts 新增 TTML 缓存表访问层(含负缓存 TTL)
electron/main/database/lyricMatchCache.ts 新增 fuzzy 匹配映射缓存(track 指纹 → 平台 id/extra)
electron/main/database/lyricCache.ts 新增歌词接口返回缓存(platform+platform_id → result JSON)
electron/main/database/index.ts 初始化 DB 时创建歌词相关表,并对旧 lyric_cache schema 做丢弃重建保护
electron/main/apis/qqmusic/modules/lyric.ts QQMusic lyric 请求参数调整(songID 强制转 Number)
electron/main/apis/qqmusic/index.ts callQQMusic 返回类型放宽为 any
electron/main/apis/qqmusic/core/request.ts QQMusic 请求增加 8s 超时
electron/main/apis/netease/index.ts callNetease 返回体类型放宽为 any
electron/main/apis/netease/core/request.ts Netease 请求增加 8s 超时
electron/main/apis/kugou/index.ts callKugou 返回类型放宽为 any
electron/main/apis/kugou/core/request.ts Kugou 请求逻辑调整:不再内部重试,增加 8s 超时与成功码兼容
electron/main/apis/common/lyric/utils.ts 新增跨平台候选归一化与 pickBestCandidate 打分挑选
electron/main/apis/common/lyric/ttml.ts 新增 AMLL TTML DB 抓取(inflight 去重 + 正/负缓存策略)
electron/main/apis/common/lyric/qqmusic.ts 新增 QQMusic 歌词匹配实现(byId/byQuery + 缓存 + TTML 预热)
electron/main/apis/common/lyric/netease.ts 新增 Netease 歌词匹配实现(byId/byQuery + 缓存 + TTML 预热)
electron/main/apis/common/lyric/kugou.ts 新增 Kugou 歌词匹配实现(byId/byQuery + 缓存)
components.d.ts 组件自动类型声明:新增 AmllDbServerConfig/SPopselect

Comment thread src/services/lyricLoader.ts Outdated
Comment on lines +172 to +179
const localSource = detail ? pickLocalSource(detail) : null;
const localContent = localSource && detail ? await readLocalContent(detail, localSource) : null;
if (token !== currentToken) return;
// 本地立即显示
if (localSource) commit(token, localSource, localContent ? { content: localContent } : null);
// 按偏好获取歌词
const online = await tryOnlineByPreference(token, track, !!localSource);
if (token !== currentToken) return;
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadForTrack 里用 !!localSource 作为 hasLocal 会把“本地歌词文件存在但读取失败(localContent=null)”也当成“有本地歌词”,从而在 preference=auto 时直接跳过在线匹配;同时 commit(token, localSource, null) 会把 activeLyric 设为 external/embedded 但 content 为空,导致 UI/主进程认为已有歌词源却实际无内容。建议:hasLocal 改为基于 localContent != null(或读取成功)判断;读取失败时不要 commit localSource(或先置空 source),继续走在线 fallback/最终 commit(null,null)。

Copilot uses AI. Check for mistakes.
Comment thread src/services/lyricLoader.ts Outdated
Comment on lines +199 to +218
const localSource = detail ? pickLocalSource(detail) : null;
const preference = useSettingsStore().lyric.lyricSourcePreference;
const showingOnline = media.activeLyric?.source === "online";

// 目标应当显示本地
const shouldShowLocal = preference === "self" || (preference === "auto" && !!localSource);
if (shouldShowLocal) {
if (!showingOnline) return;
if (localSource && detail) {
const content = await readLocalContent(detail, localSource);
commit(token, localSource, content ? { content } : null);
} else {
commit(token, null, null);
}
return;
}

// 其它情况需要查在线
const online = await tryOnlineByPreference(token, track, !!localSource);
if (token !== currentToken) return;
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refreshPreference 里 shouldShowLocal/tryOnlineByPreference(..., !!localSource) 只看 localSource 是否存在,不看本地歌词是否真的可读;如果本地文件损坏/读失败,会导致 auto/self 场景强制回到“本地”但内容为 null。建议:把“本地可用”的判定改成“已成功读取到 content”,或在读失败时回落到在线/清空,而不是 commit(localSource, null)。

Copilot uses AI. Check for mistakes.
Comment thread src/services/lyricLoader.ts Outdated
Comment on lines +27 to +47
/** 挑本地歌词源:外置 > 内嵌 > null */
const pickLocalSource = (detail: TrackDetail): LyricData => {
const idx = bestExternalIndex(detail.externalLyrics);
if (idx !== -1) {
return { source: "external", format: detail.externalLyrics[idx].format };
}
if (detail.embeddedLyric) {
return { source: "embedded", format: detectFormat(detail.embeddedLyric) };
}
return null;
};

/** 读取本地歌词内容:external 读文件,embedded 直取 detail 字段 */
const readLocalContent = async (detail: TrackDetail, source: LyricData): Promise<string | null> => {
if (!source) return null;
if (source.source === "embedded") return detail.embeddedLyric ?? null;
if (source.source === "external") {
const ext = detail.externalLyrics.find((item) => item.format === source.format);
if (!ext) return null;
const result = await window.api.player.readLyricFile(ext.path);
return result.success ? (result.data ?? null) : null;
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readLocalContent 在 external 分支用 find((item) => item.format === source.format) 重新选文件;但 pickLocalSource 是按 bestExternalIndex(detail.externalLyrics) 选出的“某一个具体条目”。如果同一格式存在多个外部歌词文件,这里可能读到与 bestExternalIndex 不一致的文件。建议:在 pickLocalSource 里把选中的 path(或 index)也带出来,readLocalContent 直接按该 path 读取,避免错配。

Copilot uses AI. Check for mistakes.
Comment thread src/core/player.ts
Comment on lines 44 to 60
const media = useMediaStore();
media.setTrack(data.track, data.detail);
media.loadLyric();
lyricLoader.loadForTrack(data.detail);
extractColorFromUrl(data.track.cover ?? null);
const dur = data.track.duration;
status.duration = dur;
status.position = 0;
status.state = autoPlay ? "playing" : "paused";
status.currentSource = source;
playback.setDuration(dur);
playback.setCurrentTime(0);
playback.setCurrentTime(0, { force: true });
playback.setPlaying(autoPlay);
return data.track;
} else {
status.state = "idle";
lyricLoader.loadForTrack(null);
if (error) {
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里直接调用了 async 的 lyricLoader.loadForTrack(...) 但既不 await 也不显式 .catch;loadForTrack 内部有多处 await(读文件/IPC)且没有 try/catch,任何异常都会变成未处理的 Promise rejection。建议:要么 await 并在本层兜底处理错误,要么用 void lyricLoader.loadForTrack(...).catch(...) 并确保 loadForTrack 内部吞掉异常。

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +72
const resolveById = async (platform: Platform, id: string): Promise<LyricMatchResponse> => {
try {
switch (platform) {
case "netease":
return { ok: true, data: await netease.getByPlatformId(id) };
case "qqmusic":
return { ok: true, data: await qqmusic.getByPlatformId(id) };
case "kugou":
return { ok: true, data: await kugou.getByPlatformId(id) };
}
} catch (err) {
coreLog.warn(`[lyrics] matchById(${platform}, ${id}) failed:`, err);
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
};

/** 按 Track 元数据 fuzzy 匹配 */
const resolveByQuery = async (platform: Platform, track: Track): Promise<LyricMatchResponse> => {
try {
switch (platform) {
case "netease":
return { ok: true, data: await netease.getByQuery(track) };
case "qqmusic":
return { ok: true, data: await qqmusic.getByQuery(track) };
case "kugou":
return { ok: true, data: await kugou.getByQuery(track) };
}
} catch (err) {
coreLog.warn(`[lyrics] matchByQuery(${platform}, ${track.title}) failed:`, err);
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
};
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveById/resolveByQuery 的 switch 没有 default/兜底 return;一旦 renderer 侧传入了非法 platform(preload 目前是 string/unknown),这里会返回 undefined,违反 LyricMatchResponse 约定并可能导致渲染端逻辑异常。建议:在 switch 之后增加兜底 return { ok: false, error: 'unsupported platform' },并在 IPC handler 层对 platform 做运行时校验。

Copilot uses AI. Check for mistakes.
Comment thread src/stores/media.ts
Comment on lines 61 to 72
const setTrack = (newTrack: Track, newDetail?: TrackDetail): void => {
track.value = newTrack;
if (!newDetail) return;
detail.value = newDetail;

// 同步选择歌词源:外置优先
const idx = bestExternalIndex(newDetail.externalLyrics);
if (idx !== -1) {
activeLyric.value = {
source: "external",
format: newDetail.externalLyrics[idx].format,
};
} else if (newDetail.embeddedLyric) {
activeLyric.value = { source: "embedded", format: detectFormat(newDetail.embeddedLyric) };
} else {
activeLyric.value = null;
}
detail.value = newDetail ?? null;
};

/** 加载歌词内容 */
const loadLyric = async (): Promise<void> => {
const token = ++lyricToken;
const det = detail.value;
const active = activeLyric.value;
if (!det || !active) {
lyricContent.value = null;
lyricLoading.value = false;
syncToMain();
return;
}
/** 重置歌词状态 */
const resetLyricState = (): void => {
activeLyric.value = null;
lyricContent.value = null;
lyricIndex.value = -1;
lyricLoading.value = true;
try {
const content = await loadLyricContent(det, active);
// 竞态保护:加载期间如果已切歌,丢弃结果
if (token !== lyricToken) return;
lyricContent.value = content;
lyricIndex.value = -1;
syncToMain();
} finally {
if (token === lyricToken) lyricLoading.value = false;
}
};
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setTrack/resetLyricState 只更新了 store 内的 track/lyric 状态,但没有调用 syncToMain;而 syncToMain 是主进程 NowPlaying/歌词窗口的数据源。这样切歌/开始加载歌词时,主进程可能在一段时间内仍看到上一首的 track/lyric(直到 setLyric/clear 才同步)。建议:在 setTrack 与 resetLyricState(或 lyricLoader.beginLoad)里同步一次(track 更新 + lyric 清空/loading 标记),保证跨进程状态不会短暂错位。

Copilot uses AI. Check for mistakes.
Comment thread src/core/player.ts
Comment on lines +94 to +96
// 乐观更新:同步写入 track,开启歌词加载周期
useMediaStore().setTrack(track);
lyricLoader.beginLoad();
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadTrack 里先调用 lyricLoader.beginLoad(),但后续 load() 成功/失败又会走 lyricLoader.loadForTrack(...),而 loadForTrack 内部会再次 beginLoad。这样会重复递增 token/重复 reset 状态,增加不必要的状态抖动。建议:统一由一个入口开启加载周期(例如:loadTrack 只 beginLoad,然后把 token 传给 loadForTrack;或移除这里的 beginLoad)。

Suggested change
// 乐观更新:同步写入 track,开启歌词加载周期
useMediaStore().setTrack(track);
lyricLoader.beginLoad();
// 乐观更新:同步写入 track;歌词加载周期由后续统一入口开启,避免重复 beginLoad
useMediaStore().setTrack(track);

Copilot uses AI. Check for mistakes.
Comment thread electron/preload/index.ts
Comment on lines +251 to +260
lyrics: {
// 按 id 直取某平台歌词
matchById: (platform: string, id: string) =>
ipcRenderer.invoke("lyrics:matchById", platform, id),
// 按 Track 元数据在某平台模糊搜索歌词
matchByQuery: (platform: string, track: unknown) =>
ipcRenderer.invoke("lyrics:matchByQuery", platform, track),
// 获取 AMLL TTML DB 的 TTML
fetchTTMLOverlay: (track: unknown, platform: string) =>
ipcRenderer.invoke("lyrics:fetchTTMLOverlay", track, platform),
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preload 侧 lyrics API 目前把 platform 声明成 string、track 声明成 unknown,会绕开 LyricsApi 的类型约束,也让非法 platform 更容易穿透到主进程。建议:参数类型对齐 LyricsApi(platform 用 Platform 联合类型、track 用 Track;fetchTTMLOverlay 的 platform 用 "netease"|"qqmusic"),并在进入 ipcRenderer.invoke 前做一次简单校验/收敛。

Copilot uses AI. Check for mistakes.
Comment on lines 57 to 63
qrc_t: 0,
roma: 1,
roma_t: 0,
singerName: b64(artist),
songID: id,
songID: Number(id),
songName: b64(name),
trans: 1,
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里把 songID 强制转成 Number(id):当上层误传非数字(例如 mid、空串)时会变成 NaN,可能导致 QQMusic 接口返回不可预期结果且不易定位。建议:在构造参数前校验 id 是否为有限数字;不合法时尽早抛错/返回明确错误码,或保持字符串并在上层保证传入的是数字 id。

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 27, 2026 09:12
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 52 out of 55 changed files in this pull request and generated 5 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

...new Set([...defaultExcludeRegexes, ...settings.excludeLyricsUserRegexes]),
];

const artistNames = track?.artists?.map((a) => a.name).filter(Boolean) ?? [];
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

artistNames 这一行的可选链写法在 track 为 null/undefined 时仍会对 undefined 调用 .filter(...)track?.artists?.map(...).filter(...)),运行期会直接抛错。建议改成对 map 结果也使用可选链(或用条件分支在 track 为空时返回空数组)。

Copilot uses AI. Check for mistakes.
Comment thread src/pages/Home.vue
* @param params 业务参数;不想命中缓存可传 `timestamp: Date.now()`
*/
export const callQQMusic = async (name: string, params: QMParams = {}): Promise<unknown> => {
export const callQQMusic = async (name: string, params: QMParams = {}): Promise<any> => {
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里把 callQQMusic 的返回类型从 unknown 改成了 any,会让上层对接口返回的结构失去类型约束,降低静态检查价值。建议保持 unknown(或引入更具体的返回类型/泛型),由调用方在解析时显式缩窄。

Suggested change
export const callQQMusic = async (name: string, params: QMParams = {}): Promise<any> => {
export const callQQMusic = async (name: string, params: QMParams = {}): Promise<unknown> => {

Copilot uses AI. Check for mistakes.
name: string,
params: Record<string, unknown> = {},
): Promise<{ status: number; body: unknown }> => {
): Promise<{ status: number; body: any }> => {
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里把 callNeteasebody 返回类型改成了 any。由于这些响应来自外部网络/IPC,保持 unknown 能强制调用方做结构检查与缩窄,避免误用字段。建议改回 unknown 或提供受控的返回类型/泛型。

Suggested change
): Promise<{ status: number; body: any }> => {
): Promise<{ status: number; body: unknown }> => {

Copilot uses AI. Check for mistakes.
* @param params 业务参数;不想命中缓存可传 `timestamp: Date.now()`
*/
export const callKugou = async (name: string, params: KGParams = {}): Promise<unknown> => {
export const callKugou = async (name: string, params: KGParams = {}): Promise<any> => {
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里把 callKugou 的返回类型从 unknown 改成了 any,会让上层对返回数据的字段访问不再有约束(尤其是外部接口返回不稳定时更容易埋坑)。建议维持 unknown,或用泛型/具体类型在调用点进行显式解析。

Copilot uses AI. Check for mistakes.
@imsyy imsyy merged commit 725ddf6 into dev Apr 27, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants