Conversation
There was a problem hiding this comment.
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 |
| 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; |
There was a problem hiding this comment.
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)。
| 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; |
There was a problem hiding this comment.
refreshPreference 里 shouldShowLocal/tryOnlineByPreference(..., !!localSource) 只看 localSource 是否存在,不看本地歌词是否真的可读;如果本地文件损坏/读失败,会导致 auto/self 场景强制回到“本地”但内容为 null。建议:把“本地可用”的判定改成“已成功读取到 content”,或在读失败时回落到在线/清空,而不是 commit(localSource, null)。
| /** 挑本地歌词源:外置 > 内嵌 > 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; |
There was a problem hiding this comment.
readLocalContent 在 external 分支用 find((item) => item.format === source.format) 重新选文件;但 pickLocalSource 是按 bestExternalIndex(detail.externalLyrics) 选出的“某一个具体条目”。如果同一格式存在多个外部歌词文件,这里可能读到与 bestExternalIndex 不一致的文件。建议:在 pickLocalSource 里把选中的 path(或 index)也带出来,readLocalContent 直接按该 path 读取,避免错配。
| 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) { |
There was a problem hiding this comment.
这里直接调用了 async 的 lyricLoader.loadForTrack(...) 但既不 await 也不显式 .catch;loadForTrack 内部有多处 await(读文件/IPC)且没有 try/catch,任何异常都会变成未处理的 Promise rejection。建议:要么 await 并在本层兜底处理错误,要么用 void lyricLoader.loadForTrack(...).catch(...) 并确保 loadForTrack 内部吞掉异常。
| 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) }; | ||
| } | ||
| }; |
There was a problem hiding this comment.
resolveById/resolveByQuery 的 switch 没有 default/兜底 return;一旦 renderer 侧传入了非法 platform(preload 目前是 string/unknown),这里会返回 undefined,违反 LyricMatchResponse 约定并可能导致渲染端逻辑异常。建议:在 switch 之后增加兜底 return { ok: false, error: 'unsupported platform' },并在 IPC handler 层对 platform 做运行时校验。
| 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; | ||
| } | ||
| }; |
There was a problem hiding this comment.
setTrack/resetLyricState 只更新了 store 内的 track/lyric 状态,但没有调用 syncToMain;而 syncToMain 是主进程 NowPlaying/歌词窗口的数据源。这样切歌/开始加载歌词时,主进程可能在一段时间内仍看到上一首的 track/lyric(直到 setLyric/clear 才同步)。建议:在 setTrack 与 resetLyricState(或 lyricLoader.beginLoad)里同步一次(track 更新 + lyric 清空/loading 标记),保证跨进程状态不会短暂错位。
| // 乐观更新:同步写入 track,开启歌词加载周期 | ||
| useMediaStore().setTrack(track); | ||
| lyricLoader.beginLoad(); |
There was a problem hiding this comment.
loadTrack 里先调用 lyricLoader.beginLoad(),但后续 load() 成功/失败又会走 lyricLoader.loadForTrack(...),而 loadForTrack 内部会再次 beginLoad。这样会重复递增 token/重复 reset 状态,增加不必要的状态抖动。建议:统一由一个入口开启加载周期(例如:loadTrack 只 beginLoad,然后把 token 传给 loadForTrack;或移除这里的 beginLoad)。
| // 乐观更新:同步写入 track,开启歌词加载周期 | |
| useMediaStore().setTrack(track); | |
| lyricLoader.beginLoad(); | |
| // 乐观更新:同步写入 track;歌词加载周期由后续统一入口开启,避免重复 beginLoad | |
| useMediaStore().setTrack(track); |
| 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), |
There was a problem hiding this comment.
preload 侧 lyrics API 目前把 platform 声明成 string、track 声明成 unknown,会绕开 LyricsApi 的类型约束,也让非法 platform 更容易穿透到主进程。建议:参数类型对齐 LyricsApi(platform 用 Platform 联合类型、track 用 Track;fetchTTMLOverlay 的 platform 用 "netease"|"qqmusic"),并在进入 ipcRenderer.invoke 前做一次简单校验/收敛。
| qrc_t: 0, | ||
| roma: 1, | ||
| roma_t: 0, | ||
| singerName: b64(artist), | ||
| songID: id, | ||
| songID: Number(id), | ||
| songName: b64(name), | ||
| trans: 1, |
There was a problem hiding this comment.
这里把 songID 强制转成 Number(id):当上层误传非数字(例如 mid、空串)时会变成 NaN,可能导致 QQMusic 接口返回不可预期结果且不易定位。建议:在构造参数前校验 id 是否为有限数字;不合法时尽早抛错/返回明确错误码,或保持字符串并在上层保证传入的是数字 id。
| ...new Set([...defaultExcludeRegexes, ...settings.excludeLyricsUserRegexes]), | ||
| ]; | ||
|
|
||
| const artistNames = track?.artists?.map((a) => a.name).filter(Boolean) ?? []; |
There was a problem hiding this comment.
artistNames 这一行的可选链写法在 track 为 null/undefined 时仍会对 undefined 调用 .filter(...)(track?.artists?.map(...).filter(...)),运行期会直接抛错。建议改成对 map 结果也使用可选链(或用条件分支在 track 为空时返回空数组)。
| * @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> => { |
There was a problem hiding this comment.
这里把 callQQMusic 的返回类型从 unknown 改成了 any,会让上层对接口返回的结构失去类型约束,降低静态检查价值。建议保持 unknown(或引入更具体的返回类型/泛型),由调用方在解析时显式缩窄。
| export const callQQMusic = async (name: string, params: QMParams = {}): Promise<any> => { | |
| export const callQQMusic = async (name: string, params: QMParams = {}): Promise<unknown> => { |
| name: string, | ||
| params: Record<string, unknown> = {}, | ||
| ): Promise<{ status: number; body: unknown }> => { | ||
| ): Promise<{ status: number; body: any }> => { |
There was a problem hiding this comment.
这里把 callNetease 的 body 返回类型改成了 any。由于这些响应来自外部网络/IPC,保持 unknown 能强制调用方做结构检查与缩窄,避免误用字段。建议改回 unknown 或提供受控的返回类型/泛型。
| ): Promise<{ status: number; body: any }> => { | |
| ): Promise<{ status: number; body: unknown }> => { |
| * @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> => { |
There was a problem hiding this comment.
这里把 callKugou 的返回类型从 unknown 改成了 any,会让上层对返回数据的字段访问不再有约束(尤其是外部接口返回不稳定时更容易埋坑)。建议维持 unknown,或用泛型/具体类型在调用点进行显式解析。
No description provided.