Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a0fc863
fix(lyric): sync clear store lyrics and strict pattern match
kid1412520 Mar 3, 2026
68830f8
add local ttml ncmid search
kid1412520 Mar 2, 2026
dd88ef8
feat: enhance local lyric matching with metadata and portable indexin…
kid1412520 Mar 2, 2026
97aa9bc
fix local lyric match
kid1412520 Mar 3, 2026
4f9037f
feat(lyric): use /search/match api for local song metadata matching
kid1412520 Mar 3, 2026
65b6b5d
feat(ui): display current netease matched scmId in local song match m…
kid1412520 Mar 4, 2026
a0c7c79
feat: parallelize TTML check to short-circuit local song matching
kid1412520 Mar 2, 2026
09205b1
feat: prioritize global TTML folder matching by musicName before onli…
kid1412520 Mar 2, 2026
d32d5ae
fix: prioritize local same-directory lyrics over global overrides
kid1412520 Mar 3, 2026
de091a1
feat: enhance local lyric matching with metadata and portable indexin…
kid1412520 Mar 2, 2026
6a3ed85
fix local lyric match
kid1412520 Mar 3, 2026
7cf4b50
feat(lyric): use /search/match api for local song metadata matching
kid1412520 Mar 3, 2026
13d1d94
feat(ui): display current netease matched scmId in local song match m…
kid1412520 Mar 4, 2026
b59c74a
feat: parallelize TTML check to short-circuit local song matching
kid1412520 Mar 2, 2026
934eee3
feat: prioritize global TTML folder matching by musicName before onli…
kid1412520 Mar 2, 2026
e268a9e
fix: prioritize local same-directory lyrics over global overrides
kid1412520 Mar 3, 2026
78fe5f8
Delete pr_manifesto.md.resolved
kid141252010 Mar 19, 2026
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
9 changes: 8 additions & 1 deletion electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ class MainProcess {
// 设置应用程序名称
electronApp.setAppUserModelId("com.imsyy.splayer");
// 启动主服务进程
await initAppServer();
try {
await initAppServer();
} catch (err) {
processLog.error("🚫 Failed to start AppServer:", err);
}
// 启动窗口
this.loadWindow = loadWindow.create();
this.mainWindow = mainWindow.create();
Expand All @@ -108,6 +112,9 @@ class MainProcess {
initIpc();
// 自动启动 WebSocket
SocketService.tryAutoStart();
}).catch(err => {
processLog.error("🚀 Fatal error during application startup:", err);
console.error("🚀 Fatal error during application startup:", err);
});
}
// 应用程序事件
Expand Down
94 changes: 88 additions & 6 deletions electron/main/ipc/ipc-file.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { app, dialog, ipcMain, shell } from "electron";
import { access, mkdir, unlink, writeFile, stat } from "node:fs/promises";
import { access, mkdir, unlink, writeFile, stat, readFile } from "node:fs/promises";
import { isAbsolute, join, normalize, relative, resolve } from "node:path";
import { createHash } from "node:crypto";
import { Worker } from "node:worker_threads";
import { ipcLog } from "../logger";
import { LocalMusicService } from "../services/LocalMusicService";
import { DownloadService } from "../services/DownloadService";
import { scanTtmlIdMapping, matchLocalTtmlByName } from "../services/TtmlScannerService";
import { MusicMetadataService } from "../services/MusicMetadataService";
import { useStore } from "../store";
import { chunkArray } from "../utils/helper";
Expand Down Expand Up @@ -53,7 +55,7 @@ const runToolsJobInWorker = async (payload: Record<string, unknown>) => {
worker.removeAllListeners("message");
worker.removeAllListeners("error");
worker.removeAllListeners("exit");
worker.terminate().catch(() => {});
worker.terminate().catch(() => { });
};

worker.once(
Expand Down Expand Up @@ -100,7 +102,7 @@ const runToolsJobInWorker = async (payload: Record<string, unknown>) => {
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
ipcLog.warn(`[AudioAnalysis] 启动分析失败: ${message}`);
worker.terminate().catch(() => {});
worker.terminate().catch(() => { });
return null;
}
};
Expand Down Expand Up @@ -145,7 +147,7 @@ const handleLocalMusicSync = async (
(current, total) => {
event.sender.send("music-sync-progress", { current, total });
},
() => {},
() => { },
);
// 处理音乐封面路径
const finalTracks = processMusicList(allTracks, coverDir);
Expand Down Expand Up @@ -257,8 +259,23 @@ const initFileIpc = (): void => {
});

// 读取本地歌词
ipcMain.handle("read-local-lyric", async (_, lyricDirs: string[], id: number) => {
return musicMetadataService.readLocalLyric(lyricDirs, id);
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);
});
Comment on lines +262 to 279
Copy link
Copy Markdown
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.


// 删除文件
Expand Down Expand Up @@ -524,6 +541,71 @@ const initFileIpc = (): void => {
return null;
}
});

// 获取并确保匹配索引目录存在
const getMatchIndexDir = async () => {
const dir = join(app.getPath("userData"), "local-data", "match-index");
try {
await access(dir);
} catch {
await mkdir(dir, { recursive: true });
}
return dir;
};

// 读取便携式本地匹配索引数据库
ipcMain.handle("get-local-match-index", async (_event, dirPath: string) => {
try {
const matchIndexDir = await getMatchIndexDir();
const dirHash = createHash("md5").update(dirPath).digest("hex");
const indexPath = join(matchIndexDir, `${dirHash}.json`);

const exists = await access(indexPath).then(() => true).catch(() => false);
if (!exists) return {};

const content = await readFile(indexPath, "utf-8");
return JSON.parse(content);
} catch (e) {
ipcLog.warn(`Failed to read local match index for ${dirPath}:`, String(e));
return {};
}
});

// 保存便携式本地匹配索引数据库
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) };
}
}
);
Comment on lines +575 to +608
Copy link
Copy Markdown
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);
    }
  }
});

};

export default initFileIpc;
92 changes: 51 additions & 41 deletions electron/main/services/CacheService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class CacheService {
"list-data": "list-data",
};

private constructor() {}
private constructor() { }

public static getInstance(): CacheService {
if (!CacheService.instance) {
Expand Down Expand Up @@ -123,60 +123,70 @@ export class CacheService {
throw new Error("不支持的缓存写入数据类型");
}

private initPromise: Promise<void> | null = null;

/**
* 初始化服务,计算初始大小,并初始化 DB
*/
public async init(): Promise<void> {
if (this.isInitialized) return;
// If an initialization is already in progress, wait for it.
if (this.initPromise) return this.initPromise;

try {
const basePath = this.getCacheBasePath();

// 确保目录存在
if (!existsSync(basePath)) await mkdir(basePath, { recursive: true });

// 初始化 DB
const dbPath = join(basePath, "cache.db");
this.db = new CacheDB(dbPath);

// 初始化文件缓存 (music, local-data)
for (const type of ["music", "local-data"] as CacheResourceType[]) {
const dir = join(basePath, this.CACHE_SUB_DIR[type]);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
} else {
// 清理可能残留的临时文件 (.tmp)
try {
const files = await readdir(dir);
for (const file of files) {
if (file.endsWith(".tmp")) {
await rm(join(dir, file), { force: true });
this.initPromise = (async () => {
try {
const basePath = this.getCacheBasePath();

// 确保目录存在
if (!existsSync(basePath)) await mkdir(basePath, { recursive: true });

// 初始化 DB
const dbPath = join(basePath, "cache.db");
this.db = new CacheDB(dbPath);

// 初始化文件缓存 (music, local-data)
for (const type of ["music", "local-data"] as CacheResourceType[]) {
const dir = join(basePath, this.CACHE_SUB_DIR[type]);
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
} else {
// 清理可能残留的临时文件 (.tmp)
try {
const files = await readdir(dir);
for (const file of files) {
if (file.endsWith(".tmp")) {
await rm(join(dir, file), { force: true });
}
}
} catch (e) {
cacheLog.warn(`⚠️ 无法清理目录中的临时文件: ${dir}`, e);
}
} catch (e) {
cacheLog.warn(`⚠️ 无法清理目录中的临时文件: ${dir}`, e);
}
// 计算初始大小
this.fileSizes[type] = await this.calculateDirSize(dir);
}
// 计算初始大小
this.fileSizes[type] = await this.calculateDirSize(dir);
}

// 清理旧的文件缓存目录
for (const type of ["list-data", "lyrics"] as CacheResourceType[]) {
const dir = join(basePath, this.CACHE_SUB_DIR[type]);
if (existsSync(dir)) {
await rm(dir, { recursive: true, force: true });
// 清理旧的文件缓存目录
for (const type of ["list-data", "lyrics"] as CacheResourceType[]) {
const dir = join(basePath, this.CACHE_SUB_DIR[type]);
if (existsSync(dir)) {
await rm(dir, { recursive: true, force: true });
}
}
}

this.isInitialized = true;
cacheLog.info("CacheService initialized.");
this.isInitialized = true;
cacheLog.info("CacheService initialized.");

// 启动时触发一次清理检查
this.checkAndCleanCache().catch((e) => cacheLog.warn("Startup cache cleanup failed:", e));
} catch (error) {
cacheLog.error("CacheService init failed:", error);
}
// 启动时触发一次清理检查
this.checkAndCleanCache().catch((e) => cacheLog.warn("Startup cache cleanup failed:", e));
} catch (error) {
this.initPromise = null; // Allow retry
cacheLog.error("CacheService init failed:", error);
throw error;
}
})();

return this.initPromise;
}

/**
Expand Down
49 changes: 6 additions & 43 deletions electron/main/services/MusicMetadataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getFileID, getFileMD5, metaDataLyricsArrayToLrc } from "../utils/helper
import { loadNativeModule } from "../utils/native-loader";
import FastGlob from "fast-glob";
import pLimit from "p-limit";
import { readLocalLyricImpl } from "./TtmlScannerService";

type toolModule = typeof import("@native/tools");
const tools: toolModule = loadNativeModule("tools.node", "tools");
Expand Down Expand Up @@ -215,53 +216,15 @@ export class MusicMetadataService {

/**
* 读取本地目录中的歌词(通过ID查找)
* 支持 ncmMusicId 元数据缓存匹配和文件名模式匹配
* @param lyricDirs 歌词目录列表
* @param id 歌曲ID
* @param songName 本地歌曲名称
* @param artists 歌曲对应的歌手数组
* @returns 歌词内容
*/
async readLocalLyric(lyricDirs: string[], id: number): Promise<{ lrc: string; ttml: string }> {
const result = { lrc: "", ttml: "" };

try {
// 定义需要查找的模式
const patterns = {
ttml: `**/{,*.}${id}.ttml`,
lrc: `**/{,*.}${id}.lrc`,
};

// 遍历每一个目录
for (const dir of lyricDirs) {
try {
// 查找 ttml
if (!result.ttml) {
const ttmlFiles = await FastGlob(patterns.ttml, globOpt(dir));
if (ttmlFiles.length > 0) {
const filePath = join(dir, ttmlFiles[0]);
await access(filePath);
result.ttml = await readFile(filePath, "utf-8");
}
}

// 查找 lrc
if (!result.lrc) {
const lrcFiles = await FastGlob(patterns.lrc, globOpt(dir));
if (lrcFiles.length > 0) {
const filePath = join(dir, lrcFiles[0]);
await access(filePath);
result.lrc = await readFile(filePath, "utf-8");
}
}

// 如果两种文件都找到了就提前结束搜索
if (result.ttml && result.lrc) break;
} catch {
// 某个路径异常,跳过
}
}
} catch {
/* 忽略错误 */
}
return result;
async readLocalLyric(lyricDirs: string[], id: number, songName?: string, artists?: string[]): Promise<{ lrc: string; ttml: string; matchedNcmId?: number }> {
return readLocalLyricImpl(lyricDirs, id, songName, artists);
}

/**
Expand Down
Loading