Files
AlgerMusicPlayer/src/renderer/store/modules/localMusic.ts
T
alger 3fc25ba9a4 feat(local-music): 支持从本地列表移除单曲并防止扫描失败误删(#713)
- 右键菜单新增'从本地列表移除'(本地歌曲自动切换文案,仅移除条目不删文件,5 语言文案)
- localMusic store 新增 removeEntry action
- 扫描失败的文件夹不再参与'已删除清理',避免移动盘/网络盘暂时不可用时整夹歌曲被误删
- 注:'刷新不清理已删除歌曲'主症状已由 c28368f 修复,本次补齐评论区诉求与防护

Closes #713
2026-07-05 14:35:00 +08:00

316 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 本地音乐 Pinia Store
// 管理本地音乐列表、扫描状态和文件夹配置
// 使用 IndexedDB 缓存音乐元数据,localStorage 持久化文件夹路径
import { createDiscreteApi } from 'naive-ui';
import { defineStore } from 'pinia';
import { ref } from 'vue';
import useIndexedDB from '@/hooks/IndexDBHook';
import type { LocalMusicEntry } from '@/types/localMusic';
import { removeStaleEntries } from '@/utils/localMusicUtils';
const { message } = createDiscreteApi(['message']);
/** IndexedDB store 名称 */
const LOCAL_MUSIC_STORE = 'local_music' as const;
/** IndexedDB 数据类型映射 */
type LocalMusicDBStores = {
local_music: LocalMusicEntry;
};
/**
* 使用 filePath 生成唯一 ID
* 采用简单的字符串 hash 算法,确保同一路径始终生成相同 ID
* @param filePath 文件绝对路径
* @returns hash 字符串作为唯一 ID
*/
function generateId(filePath: string): string {
let hash = 0;
for (let i = 0; i < filePath.length; i++) {
const char = filePath.charCodeAt(i);
hash = ((hash << 5) - hash + char) | 0;
}
// 转为正数的十六进制字符串
return (hash >>> 0).toString(16);
}
/**
* 初始化 IndexedDB 实例
* 使用 localMusicDB 数据库,包含 local_music 表
*/
async function initLocalMusicDB() {
return await useIndexedDB<typeof LOCAL_MUSIC_STORE, LocalMusicDBStores>(
'localMusicDB',
[{ name: LOCAL_MUSIC_STORE, keyPath: 'id' }],
1
);
}
/**
* 本地音乐管理 Store
* 负责:文件夹管理、音乐扫描、IndexedDB 缓存、增量更新
*/
export const useLocalMusicStore = defineStore(
'localMusic',
() => {
// ==================== 状态 ====================
/** 已配置的文件夹路径列表 */
const folderPaths = ref<string[]>([]);
/** 本地音乐列表(从 IndexedDB 加载) */
const musicList = ref<LocalMusicEntry[]>([]);
/** 是否正在扫描 */
const scanning = ref(false);
/** 已扫描文件数(用于显示进度) */
const scanProgress = ref(0);
/** IndexedDB 实例(延迟初始化) */
let db: Awaited<ReturnType<typeof initLocalMusicDB>> | null = null;
/**
* 获取 IndexedDB 实例,首次调用时初始化
*/
async function getDB() {
if (!db) {
db = await initLocalMusicDB();
}
return db;
}
// ==================== 动作 ====================
/**
* 添加文件夹路径
* 如果路径已存在则忽略
* @param path 文件夹路径
*/
function addFolder(path: string): void {
if (!path || folderPaths.value.includes(path)) {
return;
}
folderPaths.value.push(path);
}
/**
* 移除文件夹路径
* @param path 要移除的文件夹路径
*/
function removeFolder(path: string): void {
const index = folderPaths.value.indexOf(path);
if (index !== -1) {
folderPaths.value.splice(index, 1);
}
}
/**
* 扫描所有已配置的文件夹
* 流程:IPC 扫描文件 → 增量对比 → 解析变更文件元数据 → 存入 IndexedDB → 更新列表
*/
async function scanFolders(): Promise<void> {
if (scanning.value || folderPaths.value.length === 0) {
return;
}
scanning.value = true;
scanProgress.value = 0;
try {
const localDB = await getDB();
// 加载当前缓存数据用于增量对比
const cachedEntries = await localDB.getAllData(LOCAL_MUSIC_STORE);
const cachedMap = new Map<string, LocalMusicEntry>();
for (const entry of cachedEntries) {
cachedMap.set(entry.filePath, entry);
}
// 磁盘上实际存在的文件路径集合(扫描时收集)
const diskFilePaths = new Set<string>();
// 扫描失败的文件夹:其下的缓存条目不参与"已删除清理",
// 避免移动盘/网络盘暂时不可用时整个文件夹的歌曲被误删(#713)
const failedFolders: string[] = [];
// 遍历每个文件夹进行扫描
for (const folderPath of folderPaths.value) {
try {
// 1. 调用 IPC 扫描文件夹,获取文件路径与修改时间
const result = await window.api.scanLocalMusicWithStats(folderPath);
// 检查是否返回错误
if ((result as any).error) {
console.error(`扫描文件夹失败: ${folderPath}`, (result as any).error);
message.error(`扫描失败: ${(result as any).error}`);
failedFolders.push(folderPath);
continue;
}
const { files } = result;
scanProgress.value += files.length;
// 记录磁盘上存在的文件
for (const file of files) {
diskFilePaths.add(file.path);
}
// 2. 增量扫描:基于修改时间筛选需重新解析的文件
// 老条目(无 coverPath 字段)也视为需要重新解析,让数据自愈到统一格式
const parseTargets: string[] = [];
for (const file of files) {
const cached = cachedMap.get(file.path);
if (
!cached ||
cached.modifiedTime !== file.modifiedTime ||
!('coverPath' in cached)
) {
parseTargets.push(file.path);
}
}
// 3. 仅解析新增或变更文件,避免对未变更文件重复解析元数据
if (parseTargets.length > 0) {
const metas = await window.api.parseLocalMusicMetadata(parseTargets);
for (const meta of metas) {
const entry: LocalMusicEntry = {
...meta,
id: generateId(meta.filePath)
};
await localDB.saveData(LOCAL_MUSIC_STORE, entry);
cachedMap.set(entry.filePath, entry);
}
}
} catch (error) {
console.error(`扫描文件夹出错: ${folderPath}`, error);
message.error(`扫描文件夹出错: ${folderPath}`);
failedFolders.push(folderPath);
}
}
/** 判断文件路径是否位于某个扫描失败的文件夹下 */
const isUnderFailedFolder = (filePath: string): boolean =>
failedFolders.some((folder) => {
if (!filePath.startsWith(folder)) return false;
if (folder.endsWith('/') || folder.endsWith('\\')) return true;
const next = filePath.charAt(folder.length);
return next === '/' || next === '\\';
});
// 4. 清理已删除文件:从 IndexedDB 移除磁盘上不存在的条目
// (扫描失败的文件夹跳过清理,其文件未被枚举并不代表已删除)
for (const [filePath, entry] of cachedMap) {
if (!diskFilePaths.has(filePath) && !isUnderFailedFolder(filePath)) {
await localDB.deleteData(LOCAL_MUSIC_STORE, entry.id);
}
}
// 5. 从 IndexedDB 重新加载完整列表
musicList.value = await localDB.getAllData(LOCAL_MUSIC_STORE);
} catch (error) {
console.error('扫描本地音乐失败:', error);
message.error('扫描本地音乐失败');
} finally {
scanning.value = false;
}
}
/**
* 从 IndexedDB 缓存加载音乐列表
* 应用启动时或进入本地音乐页面时调用
*/
async function loadFromCache(): Promise<void> {
try {
const localDB = await getDB();
musicList.value = await localDB.getAllData(LOCAL_MUSIC_STORE);
} catch (error) {
console.error('从缓存加载本地音乐失败:', error);
// 降级:缓存加载失败时保持空列表,用户可手动触发扫描
musicList.value = [];
}
}
/**
* 从本地列表移除单个条目(仅软件层面移除,不删除磁盘文件)(#713)
* @param id 条目 IDgenerateId 生成的 hex 字符串)
*/
async function removeEntry(id: string): Promise<void> {
const localDB = await getDB();
await localDB.deleteData(LOCAL_MUSIC_STORE, id);
const index = musicList.value.findIndex((entry) => entry.id === id);
if (index !== -1) {
musicList.value.splice(index, 1);
}
}
/**
* 清理缓存:检查文件存在性,移除已不存在的文件条目
*/
async function clearCache(): Promise<void> {
try {
const localDB = await getDB();
const allEntries = await localDB.getAllData(LOCAL_MUSIC_STORE);
if (allEntries.length === 0) {
return;
}
// 构建文件存在性映射
const existsMap: Record<string, boolean> = {};
for (const entry of allEntries) {
try {
// 使用已有的 IPC 通道检查文件是否存在
const exists = await window.electron.ipcRenderer.invoke(
'check-file-exists',
entry.filePath
);
existsMap[entry.filePath] = exists !== false;
} catch {
// 检查失败时假设文件存在,避免误删
existsMap[entry.filePath] = true;
}
}
// 使用工具函数过滤出仍然存在的条目
const validEntries = removeStaleEntries(allEntries, existsMap);
const removedEntries = allEntries.filter(
(entry) => !validEntries.some((v) => v.id === entry.id)
);
// 从 IndexedDB 中删除不存在的条目
for (const entry of removedEntries) {
await localDB.deleteData(LOCAL_MUSIC_STORE, entry.id);
}
// 更新内存中的列表
musicList.value = validEntries;
} catch (error) {
console.error('清理缓存失败:', error);
}
}
return {
// 状态
folderPaths,
musicList,
scanning,
scanProgress,
// 动作
addFolder,
removeFolder,
scanFolders,
loadFromCache,
removeEntry,
clearCache
};
},
{
// 持久化配置:仅持久化文件夹路径到 localStorage
// 音乐列表存储在 IndexedDB 中,不需要 localStorage 持久化
persist: {
key: 'local-music-store',
storage: localStorage,
pick: ['folderPaths']
}
}
);