mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-07-09 02:57:31 +08:00
3fc25ba9a4
- 右键菜单新增'从本地列表移除'(本地歌曲自动切换文案,仅移除条目不删文件,5 语言文案)
- localMusic store 新增 removeEntry action
- 扫描失败的文件夹不再参与'已删除清理',避免移动盘/网络盘暂时不可用时整夹歌曲被误删
- 注:'刷新不清理已删除歌曲'主症状已由 c28368f 修复,本次补齐评论区诉求与防护
Closes #713
316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
// 本地音乐 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 条目 ID(generateId 生成的 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']
|
||
}
|
||
}
|
||
);
|