fix(local-music): 扫描后清理 AudioCovers 残留封面

封面落盘后,歌曲被删/移出库时对应封面文件不会被回收,长期累积占用磁盘。
新增 prune-local-music-covers IPC:全量扫描完成后渲染进程传入当前所有有效
coverPath,主进程按 basename 比对删除目录内孤儿文件,仅动 readdir 出来的
目录内文件、失败不影响扫描结果。

https://claude.ai/code/session_01LgUk5QMQsXYa7ZFTYpqeLu
This commit is contained in:
Claude
2026-05-18 02:22:31 +00:00
parent 4e429b6572
commit 4e59de9fbb
5 changed files with 52 additions and 0 deletions
+30
View File
@@ -364,4 +364,34 @@ export function initializeLocalMusicScanner(): void {
return [];
}
});
// 清理 AudioCovers 目录里不再被任何条目引用的残留封面文件。
// 渲染进程在完成一次全量扫描后传入当前所有有效 coverPath,主进程按 basename 比对,
// 删除集合外的孤儿文件(歌曲被删/移出库后落下的封面)。只动 readdir 出来的目录内
// 文件,无路径穿越风险。
ipcMain.handle('prune-local-music-covers', async (_, validCoverPaths: string[]) => {
try {
const dir = getCoverDir();
const valid = new Set(
(validCoverPaths || []).filter(Boolean).map((p) => path.basename(p))
);
const names = await fs.promises.readdir(dir).catch(() => [] as string[]);
let removed = 0;
await Promise.all(
names.map(async (name) => {
if (valid.has(name)) return;
try {
await fs.promises.unlink(path.join(dir, name));
removed += 1;
} catch (error) {
console.error('清理残留封面失败:', name, error);
}
})
);
return { removed };
} catch (error: any) {
console.error('清理残留封面出错:', error);
return { error: error?.message || '清理失败' };
}
});
}
+4
View File
@@ -44,6 +44,10 @@ interface API {
parseLocalMusicMetadata: (
filePaths: string[]
) => Promise<import('../renderer/types/localMusic').LocalMusicMeta[]>;
/** 清理 AudioCovers 目录里不再被引用的残留封面文件 */
pruneLocalMusicCovers: (
validCoverPaths: string[]
) => Promise<{ removed: number } | { error: string }>;
// Download manager
downloadAdd: (task: any) => Promise<string>;
downloadAddBatch: (tasks: any) => Promise<{ batchId: string; taskIds: string[] }>;
+2
View File
@@ -83,6 +83,8 @@ const api = {
ipcRenderer.invoke('scan-local-music-with-stats', folderPath),
parseLocalMusicMetadata: (filePaths: string[]) =>
ipcRenderer.invoke('parse-local-music-metadata', filePaths),
pruneLocalMusicCovers: (validCoverPaths: string[]) =>
ipcRenderer.invoke('prune-local-music-covers', validCoverPaths),
// Download manager
downloadAdd: (task: any) => ipcRenderer.invoke('download:add', task),
+12
View File
@@ -190,6 +190,18 @@ export const useLocalMusicStore = defineStore(
// 5. 从 IndexedDB 重新加载完整列表
musicList.value = await localDB.getAllData(LOCAL_MUSIC_STORE);
// 6. 清理 AudioCovers 目录里不再被任何条目引用的残留封面。
// musicList 此刻已是全量权威列表,传入所有有效 coverPath;歌曲被删/移出库后
// 落单的封面文件由主进程按 basename 比对删除。失败不影响扫描结果。
try {
const validCoverPaths = musicList.value
.map((entry) => entry.coverPath)
.filter((p): p is string => !!p);
await window.api.pruneLocalMusicCovers(validCoverPaths);
} catch (error) {
console.error('清理残留封面失败:', error);
}
} catch (error) {
console.error('扫描本地音乐失败:', error);
message.error('扫描本地音乐失败');
+4
View File
@@ -28,6 +28,10 @@ export interface IElectronAPI {
) => Promise<{ files: { path: string; modifiedTime: number }[]; count: number }>;
/** 批量解析本地音乐文件元数据 */
parseLocalMusicMetadata: (_filePaths: string[]) => Promise<LocalMusicMeta[]>;
/** 清理 AudioCovers 目录里不再被引用的残留封面文件 */
pruneLocalMusicCovers: (
_validCoverPaths: string[]
) => Promise<{ removed: number } | { error: string }>;
// Download manager
downloadAdd: (_task: any) => Promise<string>;
downloadAddBatch: (_tasks: any) => Promise<{ batchId: string; taskIds: string[] }>;