Skip to content

feat(lyric): enhance local matching with NCM metadata and prioritize local sources#969

Open
kid141252010 wants to merge 16 commits intoimsyy:devfrom
kid141252010:feat/lyric-match-ncm
Open

feat(lyric): enhance local matching with NCM metadata and prioritize local sources#969
kid141252010 wants to merge 16 commits intoimsyy:devfrom
kid141252010:feat/lyric-match-ncm

Conversation

@kid141252010
Copy link

Description

核心重构本地歌曲与云端元数据的匹配流程,并纠正了本地歌词文件的优先级。

主要改动:

  • 匹配算法重构: 引入 /search/match 接口,基于本地文件元数据进行更精确的网易云歌曲匹配。
  • 性能优化: 实现了并行化 TTML 扫描机制和短路搜索,显著提升本地大曲库的歌词匹配速度。
  • UI 增强: 在本地匹配弹窗中实时显示匹配到的网易云 ncmId

kid1412520 added 16 commits March 4, 2026 22:17
…g, fix SSRF vuln

- Added metadata-based matching for local songs (NCM/TTML).
- Implemented portable match index (.splayer-match.json) in local directories.
- Refined merging logic to respect user's lyric priority based on quality levels.
- Fixed SSRF vulnerability in /netease/lyric/ttml endpoint.
…g, fix SSRF vuln

- Added metadata-based matching for local songs (NCM/TTML).
- Implemented portable match index (.splayer-match.json) in local directories.
- Refined merging logic to respect user's lyric priority based on quality levels.
- Fixed SSRF vulnerability in /netease/lyric/ttml endpoint.
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

此拉取请求的核心目标是全面提升本地音乐库的歌词匹配体验。通过引入基于网易云元数据的新匹配算法,结合高效的 TTML 扫描和多层缓存机制,极大地提高了本地歌曲歌词的获取准确性和速度。同时,优化了歌词来源的优先级处理,并增强了用户手动纠正匹配结果的能力,使得本地音乐的歌词管理更加智能和便捷。

Highlights

  • 本地歌曲匹配增强: 引入了新的 /search/match API,结合本地文件元数据(歌名、歌手、专辑、时长、MD5)进行更精确的网易云歌曲匹配。支持 '严格'、'标准'、'宽松' 三种匹配策略,并优先检查本地便携式索引和全局 TTML 缓存,显著提升了匹配准确率和速度。
  • 歌词系统重大升级: 重构了歌词搜索逻辑,确保与歌曲同路径的 .lrc.ttml 文件具有最高优先级。增强了 TTML 歌词支持,包括智能优选简体中文翻译。新增了本地歌曲纠正功能,允许用户手动绑定网易云 ID。
  • 性能优化与缓存机制: 实现了并行化 TTML 扫描机制和短路搜索,显著提升了本地大曲库的歌词匹配速度。CacheService 的初始化逻辑得到了改进,避免了并发问题。新增了本地匹配索引的持久化存储,减少重复匹配请求。
  • 错误处理与安全性: 在 initAppServer 和 TTML 歌词获取等关键启动和网络请求环节增加了健壮的错误处理和日志记录。TTML 歌词获取接口增加了严格的 ID 校验、服务器配置合法性检查和请求超时机制,提升了安全性。
  • UI 增强与用户体验: 在本地匹配弹窗中实时显示匹配到的网易云 ncmId,并提供了验证功能。歌曲右键菜单新增了 '本地歌曲纠正' 选项,方便用户管理本地歌曲的匹配关系。
Changelog
  • electron/main/index.ts
    • initAppServer 和应用程序启动过程添加了错误处理逻辑,以提高稳定性。
  • electron/main/ipc/ipc-file.ts
    • 导入了 createHashreadFile 模块。
    • 引入了 scanTtmlIdMappingmatchLocalTtmlByName 服务。
    • 新增了 scan-ttml-lyrics IPC 处理器,用于手动扫描本地 TTML 歌词目录并建立映射缓存。
    • 新增了 match-local-ttml-by-name IPC 处理器,用于通过歌名在本地缓存中快速寻找 TTML 文件信息。
    • 新增了 get-local-match-indexsave-local-match-index IPC 处理器,用于管理便携式本地匹配索引数据库。
  • electron/main/services/CacheService.ts
    • 修改了 init 方法,引入 initPromise 以防止并发初始化,确保服务只初始化一次。
  • electron/main/services/MusicMetadataService.ts
    • 更新了 readLocalLyric 方法,使其调用新的 readLocalLyricImpl 函数,以支持更复杂的歌词匹配逻辑。
  • electron/main/services/TtmlScannerService.ts
    • 新增文件,实现了 TtmlIdMappingCache 类,用于管理 TTML ID 映射缓存(内存和 CacheDB 持久化)。
    • 新增 extractMetadataFromTTML 函数,用于从 TTML 内容中提取 ncmMusicIdmusicName
    • 新增 scanTtmlIdMapping 函数,用于扫描歌词目录中的 TTML 文件,提取元数据并建立缓存,支持并发限制和增量更新。
    • 新增 readLocalLyricImpl 函数,作为核心歌词读取逻辑,优先查询缓存,回退到文件名匹配,并在未找到时触发后台扫描。
    • 新增 matchLocalTtmlByName 函数,用于通过歌名在本地缓存中快速匹配 TTML 文件。
  • electron/main/utils/config.ts
    • 更新了 port 配置的获取逻辑,优先使用 process.env['VITE_SERVER_PORT']
  • electron/server/index.ts
    • 为 API 注册和服务器启动过程添加了详细的日志输出。
    • 改进了 AppServer 启动失败时的错误日志记录。
  • electron/server/netease/index.ts
    • 增强了 NCM API 请求和响应的日志记录,包括请求参数和响应体(截断长响应)。
    • /netease/lyric/ttml 接口增加了严格的 id 格式校验、服务器配置合法性检查和 URL 安全构造。
    • 为 TTML 歌词获取增加了 10 秒的请求超时机制,并区分超时错误和其他网络错误。
  • pr_manifesto.md.resolved
    • 新增文件,提供了自 Fork 以来所有功能增强、Bug 修复和架构优化的详细汇总文档。
  • src/api/search.ts
    • 新增 searchMatch API,用于根据歌曲元数据(标题、专辑、艺术家、时长、MD5)匹配网易云歌曲信息。
  • src/components/Modal/LocalMatch.vue
    • 新增文件,创建了本地歌曲纠正的模态框组件,允许用户手动输入并验证网易云歌曲 ID,以纠正本地歌曲的匹配关系。
  • src/components/Setting/components/LocalLyricDirectories.vue
    • 更新了本地歌词目录的描述,明确指出 TTML 文件中的 ncmMusicId 元数据也会被自动识别匹配。
  • src/components/Setting/config/lyric.ts
    • 在歌词设置中新增了 '本地歌曲网易云歌词匹配' 开关。
    • 新增了 '匹配严格度' 选项(严格、标准、宽松),允许用户配置本地歌曲与网易云元数据匹配的精确度。
  • src/composables/useSongMenu.ts
    • 为本地歌曲的右键菜单新增了 '本地歌曲纠正' 选项,点击后会打开相应的模态框。
  • src/core/player/LyricManager.ts
    • 导入了 searchMatch API。
    • 新增了 extractArtistNamesextractAlbumNameisExactMatchisContainsMatch 等辅助方法,用于元数据处理和字符串匹配。
    • 新增了 matchNCMSongByMetadata 方法,实现了基于本地元数据(包括本地索引和全局 TTML 缓存)和网易云 /search/match API 的多级匹配逻辑,并支持不同严格度。
    • 新增了 saveMatchCache 方法,用于将匹配结果保存到 CacheDB 和便携式本地索引。
    • 重构了 fetchLocalLyric 方法,使其能够集成在线匹配结果,并根据歌词质量等级进行覆盖。
    • 修改了 fetchLocalOverrideLyric 方法,增加了 songNameartists 参数,以支持通过全局目录进行更精确的覆盖查询,并返回 matchedNcmId
    • 更新了 handleLyric 方法中的歌词获取优先级逻辑,优先尝试本地文件附带歌词,然后是全局覆盖,最后是在线获取,并整合了新的 NCM 元数据匹配逻辑。
  • src/core/player/PlayerController.ts
    • 在切换歌曲时,增加了清空上一首歌的歌词缓存和相关状态的逻辑,避免显示旧歌词。
  • src/stores/setting.ts
    • SettingState 接口中新增了 localLyricNCMMatchlocalLyricMatchLevel 属性。
    • useSettingStore 的默认状态中初始化了 localLyricNCMMatchfalselocalLyricMatchLevelnormal
  • src/utils/modal.ts
    • 新增 openLocalMatch 函数,用于打开本地歌曲纠正模态框。
  • src/utils/request.ts
    • 修改了 baseURL 的确定逻辑,优先使用 VITE_API_URL 环境变量,以支持外部自建 API。
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This Pull Request significantly enhances the local music experience, particularly the lyric matching function, through core matching algorithm refactoring, performance optimization via parallel scanning and caching, and a manual correction UI. However, two critical security vulnerabilities were identified: sensitive session cookies are being logged in plain text in the NetEase API proxy, and several IPC handlers lack path validation, potentially leading to arbitrary directory scans, Denial of Service, or information disclosure. Additionally, the code contains a critical bug that could lead to runtime crashes and a race condition that could result in data loss. Please refer to the detailed comments for specific fix suggestions regarding these issues.

Comment on lines +114 to +116
const artistMatch = targetArtists.some(targetArtist =>
entry.artists.some(cacheArtist => this.isContainsMatch(cacheArtist, targetArtist))
);
Copy link
Contributor

Choose a reason for hiding this comment

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

critical

getBySongAndArtist 方法中,代码尝试访问 entry.artists 属性,但是 TtmlIdEntry 接口定义中并没有 artists 字段。这将导致在运行时出现 TypeError: Cannot read properties of undefined (reading 'some') 的严重错误。

为了修复这个问题,需要:

  1. TtmlIdEntry 接口中添加 artists: string[] 字段。
  2. 修改 extractMetadataFromTTML 函数,使其能够从 TTML 文件中提取歌手信息(如果存在),并将其包含在返回值中。
  3. 更新 TtmlIdMappingCacheset 方法,以接收并存储 artists 数据。
  4. scanTtmlIdMapping 函数中,将提取到的 artists 数据传递给 cache.set 方法。

Comment on lines +575 to +608
ipcMain.handle(
"save-local-match-index",
async (_event, dirPath: string, fileName: string, ncmId: number | null) => {
try {
const matchIndexDir = await getMatchIndexDir();
const dirHash = createHash("md5").update(dirPath).digest("hex");
const indexPath = join(matchIndexDir, `${dirHash}.json`);

let indexData: Record<string, number | null> = {};

// 先尝试读取已有索引
const exists = await access(indexPath).then(() => true).catch(() => false);
if (exists) {
const content = await readFile(indexPath, "utf-8");
try {
indexData = JSON.parse(content);
} catch {
// 解析失败不阻断,直接覆盖
}
}

// 更新记录
indexData[fileName] = ncmId;

// 写入索引文件
// 格式化输出方便用户必要时查看,也可最小化
await writeFile(indexPath, JSON.stringify(indexData, null, 2), "utf-8");
return { success: true };
} catch (e) {
ipcLog.error(`Failed to save local match index for ${dirPath}:`, String(e));
return { success: false, error: String(e) };
}
}
);
Copy link
Contributor

Choose a reason for hiding this comment

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

high

save-local-match-index IPC 处理函数存在竞态条件。如果多个请求同时针对同一个目录下的不同文件进行操作,它们会读取同一个索引文件,各自在内存中更新,然后写回。这可能导致后写入的操作覆盖掉先前的更改,造成数据丢失。

为了解决这个问题,建议引入一个基于文件路径的锁机制,确保对同一个索引文件的读-改-写操作是原子性的。可以使用一个 Map 来管理每个索引文件的 Promise 锁,将并发的写入操作串行化。

例如:

const saveIndexLocks = new Map<string, Promise<void>>();

ipcMain.handle("save-local-match-index", async (...) => {
  // ... 获取 indexPath
  const indexPath = join(matchIndexDir, `${dirHash}.json`);

  const previousLock = saveIndexLocks.get(indexPath) || Promise.resolve();

  const newLock = previousLock.then(async () => {
    // ... 原有的读、改、写逻辑
  });

  saveIndexLocks.set(indexPath, newLock);

  try {
    await newLock;
    return { success: true };
  } catch (e) {
    // ... 错误处理
    return { success: false, error: String(e) };
  } finally {
    // 清理锁,防止内存泄漏
    if (saveIndexLocks.get(indexPath) === newLock) {
      saveIndexLocks.delete(indexPath);
    }
  }
});

cookie: req.cookies,
};

serverLog.log(`🌐 Request NcmAPI: ${routerName} | params:`, JSON.stringify(params));
Copy link
Contributor

Choose a reason for hiding this comment

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

security-medium medium

The dynamicHandler logs the entire params object, which includes sensitive session cookies (req.cookies). This can leak authentication tokens to the application logs. It is recommended to redact or remove the cookie field before logging.

Suggested change
serverLog.log(`🌐 Request NcmAPI: ${routerName} | params:`, JSON.stringify(params));
serverLog.log(`🌐 Request NcmAPI: ${routerName} | params:`, JSON.stringify({ ...params, cookie: params.cookie ? '[REDACTED]' : undefined }));

Comment on lines +262 to 279
ipcMain.handle("read-local-lyric", async (_, lyricDirs: string[], id: number, songName?: string, artists?: string[]) => {
return musicMetadataService.readLocalLyric(lyricDirs, id, songName, artists);
});

// 手动扫描本地 TTML 歌词目录,建立 ncmMusicId 映射缓存
ipcMain.handle("scan-ttml-lyrics", async (_, lyricDirs: string[]) => {
try {
const count = await scanTtmlIdMapping(lyricDirs);
return { success: true, count };
} catch (error: any) {
return { success: false, message: error?.message || String(error) };
}
});

// 尝试通过歌名快速在本地缓存中寻找对应的 TTML 文件信息并提取其关联的 ncmId
ipcMain.handle("match-local-ttml-by-name", async (_, lyricDirs: string[], songName: string) => {
return matchLocalTtmlByName(lyricDirs, songName);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

security-medium medium

The IPC handlers read-local-lyric, scan-ttml-lyrics, and match-local-ttml-by-name accept arbitrary directory paths from the renderer and pass them to recursive file scanning functions (FastGlob). This allows a compromised renderer to trigger expensive disk-wide scans, leading to Denial of Service or information disclosure. Paths should be validated against an allow-list of user-defined music directories.

Comment on lines +387 to +407
if (cachedByName) {
const filePath = cachedByName.filePath;
try {
const fileStat = await stat(filePath);
if (fileStat.mtimeMs === cachedByName.mtime) {
result.ttml = await readFile(filePath, "utf-8");
result.matchedNcmId = cachedByName.ncmIds.length > 0 ? cachedByName.ncmIds[0] : undefined;
ipcLog.info(`[readLocalLyric] 歌名缓存命中 TTML: ${filePath}`);
} else {
await cache.delete(filePath, { autoSave: false });
isCacheDirty = true;
// @ts-ignore
cachedByName = undefined;
}
} catch (e) {
await cache.delete(filePath, { autoSave: false });
isCacheDirty = true;
// @ts-ignore
cachedByName = undefined;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

readLocalLyricImpl 函数中,有两处使用了 @ts-ignore 来抑制类型检查错误,这通常是不推荐的做法,并且这里的逻辑可以被简化。cachedByName 变量在类型收窄后被重新赋值为 undefined,但实际上后续代码并没有再次使用它,这使得代码意图不够清晰。

建议重构这部分逻辑,移除 @ts-ignore 和不必要的赋值操作,使代码更简洁易懂。

        if (cachedByName) {
            const filePath = cachedByName.filePath;
            try {
                const fileStat = await stat(filePath);
                if (fileStat.mtimeMs === cachedByName.mtime) {
                    result.ttml = await readFile(filePath, "utf-8");
                    result.matchedNcmId = cachedByName.ncmIds.length > 0 ? cachedByName.ncmIds[0] : undefined;
                    ipcLog.info(`[readLocalLyric] 歌名缓存命中 TTML: ${filePath}`);
                } else {
                    // 文件已更新,删除旧缓存
                    await cache.delete(filePath, { autoSave: false });
                    isCacheDirty = true;
                }
            } catch (e) {
                // 访问文件失败,删除旧缓存
                await cache.delete(filePath, { autoSave: false });
                isCacheDirty = true;
            }
        }

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.

1 participant