import { fileIo, WatchEvent } from '@kit.CoreFileKit'; import { FileUtil, PreferencesUtil } from '@pura/harmony-utils'; import Logger from './Logger'; import MediaTable from './MediaTable'; import { VideoItem } from '../../viewmodel/VideoItem'; import { Utility } from './Utility'; import { CommonConstants } from '../constants/CommonConstants'; const TAG = 'heanup FileDeletionWatcher'; const WATCH_EVENT_MASK = 0x200 | 0x400 | 0x40 | 0x80 | 0x100; // 删除、目录自删、移出、移入和新建 const DELETE_EVENT_MASK = 0x200 | 0x400 | 0x40; const CREATE_EVENT_MASK = 0x100; const MOVED_TO_EVENT_MASK = 0x80; const CONSISTENCY_SCAN_INTERVAL = 10000; const CONSISTENCY_BATCH_SIZE = 5; /** * 负责监听指定目录下的文件删除事件,并同步更新数据库。 */ export default class FileDeletionWatcher { private watchers: Map = new Map(); private dbReadyPromise?: Promise; private context: Context; private mediaTable: MediaTable; private scanTimer?: number; private scanning: boolean = false; private nextScanIndex: number = 0; constructor(context: Context, mediaTable: MediaTable) { this.context = context; this.mediaTable = mediaTable; } /** * 启动目录监听,重复调用会重新建立监听,确保路径列表更新生效。 */ async watchDirectories(paths: string[]): Promise { const targets = Array.from(new Set( paths .map(path => this.normalizePath(path)) .filter(path => !!path) )); if (targets.length === 0) { return; } await this.ensureDbReady(); this.stop(); targets.forEach(path => this.registerRecursive(path)); this.scheduleConsistencyCheck(); await this.runConsistencyBatch(); // 启动时先做一次全量校验,确保数据库与实际文件同步 } /** * 停止所有监听。 */ stop(): void { if (this.scanTimer) { clearInterval(this.scanTimer); this.scanTimer = undefined; } this.scanning = false; this.nextScanIndex = 0; this.watchers.forEach((watcher, path) => { try { watcher.stop(); Logger.info(TAG, `停止监听目录: ${path}`); console.info(`[FileWatcher] 停止监听目录: ${path}`); } catch (error) { Logger.warn(TAG, `停止监听目录失败: ${path}, ${(error as Error).message}`); console.warn(`[FileWatcher] 停止监听目录失败: ${path}, ${(error as Error).message}`); } }); this.watchers.clear(); } private async ensureDbReady(): Promise { if (!this.dbReadyPromise) { this.dbReadyPromise = new Promise(resolve => { this.mediaTable.getRdbStore(this.context, () => resolve()); }); } return this.dbReadyPromise; } private registerRecursive(rawPath: string): void { const path = this.normalizePath(rawPath); if (!path || this.watchers.has(path) || !this.canAccess(path)) { return; } try { const watcher = fileIo.createWatcher(path, WATCH_EVENT_MASK, (event: WatchEvent) => { this.handleWatchEvent(event); }); watcher.start(); this.watchers.set(path, watcher); Logger.info(TAG, `开始监听目录: ${path}`); console.info(`[FileWatcher] 开始监听目录: ${path}`); } catch (error) { Logger.error(TAG, `监听目录失败: ${path}, ${(error as Error).message}`); console.error(`[FileWatcher] 监听目录失败: ${path}, ${(error as Error).message}`); return; } // 递归监听已有的子目录,避免漏掉深层目录的删除事件 try { const entries = FileUtil.listFileSync(path); entries.forEach(name => { const childPath = `${path}/${name}`; if (this.isDirectory(childPath)) { this.registerRecursive(childPath); } }); } catch (error) { Logger.warn(TAG, `遍历目录失败: ${path}, ${(error as Error).message}`); } } private handleWatchEvent(event: WatchEvent): void { const targetPath: string = this.normalizePath(event.fileName ?? ''); const mask: number = event.event ?? 0; if (!targetPath) { return; } this.logWatchEvent(targetPath, mask); if ((mask & CREATE_EVENT_MASK) !== 0 || (mask & MOVED_TO_EVENT_MASK) !== 0) { this.handlePotentialAddition(targetPath); return; } if ((mask & DELETE_EVENT_MASK) !== 0) { this.onFileDeleted(targetPath, mask); } } private tryRegisterNewDirectory(path: string): void { if (this.isDirectory(path)) { this.registerRecursive(path); } } private handlePotentialAddition(path: string): void { if (this.isDirectory(path)) { this.registerRecursive(path); return; } this.handleFileAdded(path).catch((error: Error) => { Logger.error(TAG, `新增文件处理失败: ${path}, ${error.message}`); console.error(`[FileWatcher] 新增文件处理失败: ${path}, ${error.message}`); }); } private onFileDeleted(path: string, mask: number): void { if (this.watchers.has(path) || (mask & 0x400) !== 0) { // 目录被删除,停止监听并删除其下所有记录 this.stopWatcher(path); this.deleteByParentPath(path); return; } this.deleteByFilePath(path); } private deleteByFilePath(path: string): void { this.mediaTable.deleteDataFilePath(path, (success: boolean) => { if (success) { this.logRemoval(path, 'watch'); } else { Logger.warn(TAG, `删除文件记录失败或不存在: ${path}`); console.warn(`[FileWatcher] 删除文件记录失败或不存在: ${path}`); } }); } private deleteByParentPath(path: string): void { this.mediaTable.deleteDataForParentPath(path, (success: boolean) => { if (success) { Logger.info(TAG, `数据库已清理目录下的所有文件: ${path}`); console.info(`[FileWatcher] 数据库已清理目录下的所有文件: ${path}`); } else { Logger.warn(TAG, `未找到需要删除的目录记录: ${path}`); console.warn(`[FileWatcher] 未找到需要删除的目录记录: ${path}`); } }); } private stopWatcher(path: string): void { const watcher = this.watchers.get(path); if (!watcher) { return; } try { watcher.stop(); Logger.info(TAG, `目录被删除,停止监听: ${path}`); console.info(`[FileWatcher] 目录被删除,停止监听: ${path}`); } catch (error) { Logger.warn(TAG, `停止监听失败: ${path}, ${(error as Error).message}`); console.warn(`[FileWatcher] 停止监听失败: ${path}, ${(error as Error).message}`); } this.watchers.delete(path); } private canAccess(path: string): boolean { try { return FileUtil.accessSync(path); } catch (error) { Logger.warn(TAG, `路径不可访问: ${path}, ${(error as Error).message}`); console.warn(`[FileWatcher] 路径不可访问: ${path}, ${(error as Error).message}`); return false; } } private isDirectory(path: string): boolean { try { return FileUtil.isDirectory(path); } catch (error) { return false; } } private normalizePath(path: string): string { if (!path) { return ''; } let normalized = path.trim(); try { normalized = FileUtil.getFileUri(normalized).path; } catch (error) { // ignore, path 可能已经是普通路径 } if (normalized.length === 0) { return ''; } if (!normalized.startsWith('/')) { normalized = `/${normalized}`; } while (normalized.endsWith('/') && normalized.length > 1) { normalized = normalized.substring(0, normalized.length - 1); } return normalized; } private logWatchEvent(path: string, mask: number): void { const hexMask = `0x${mask.toString(16)}`; const message = `[FileWatcher] 监听回调: path=${path}, event=${hexMask}`; Logger.info(TAG, message); console.info(message); } private logRemoval(path: string, source: string): void { const message = `[FileWatcher:${source}] 检测到文件被删除并同步数据库: ${path}`; Logger.info(TAG, message); console.info(message); } private scheduleConsistencyCheck(): void { if (this.scanTimer) { return; } this.scanTimer = setInterval(() => { this.runConsistencyBatch(); }, CONSISTENCY_SCAN_INTERVAL); } private async runConsistencyBatch(): Promise { if (this.scanning) { return; } const dirs = Array.from(this.watchers.keys()); if (dirs.length === 0) { return; } this.scanning = true; try { const batchSize = Math.min(CONSISTENCY_BATCH_SIZE, dirs.length); for (let i = 0; i < batchSize; i++) { const index = (this.nextScanIndex + i) % dirs.length; const dir = dirs[index]; await this.ensureDirectoryConsistency(dir); } this.nextScanIndex = (this.nextScanIndex + batchSize) % dirs.length; } finally { this.scanning = false; } } private async ensureDirectoryConsistency(dir: string): Promise { await new Promise((resolve) => { this.mediaTable.queryByParentPath(dir, async (items: Array) => { for (let i = 0; i < items.length; i++) { await this.removeIfMissing(items[i].filePath); } resolve(); }); }); } private async removeIfMissing(filePath?: string): Promise { if (!filePath) { return; } let exists = true; try { exists = FileUtil.accessSync(filePath); } catch (_) { exists = false; } if (exists) { return; } await new Promise((resolve) => { this.mediaTable.deleteDataFilePath(filePath, (success: boolean) => { if (success) { this.logRemoval(filePath, 'scan'); } else { Logger.warn(TAG, `清理不存在的文件记录失败: ${filePath}`); console.warn(`[FileWatcher] 清理不存在的文件记录失败: ${filePath}`); } resolve(); }); }); } private async handleFileAdded(filePath?: string): Promise { if (!filePath) { return; } const normalizedPath = this.normalizePath(filePath); if (!normalizedPath || !this.canAccess(normalizedPath) || this.isDirectory(normalizedPath)) { return; } if (!Utility.isMeidaByExtension(normalizedPath)) { return; } const autoParse = PreferencesUtil.getBooleanSync('autoParseMusicName', true); const coverApi = PreferencesUtil.getStringSync('COVER_API', CommonConstants.COVER_API); try { const mediaItem = await Utility.uriGetMusicAssetsFromFile( this.context, normalizedPath, CommonConstants.TYPE_LOCAL, autoParse ); await new Promise((resolve) => { this.mediaTable.insert(mediaItem, () => resolve(), coverApi); }); this.logAddition(normalizedPath); } catch (error) { Logger.error(TAG, `新增文件解析或入库失败: ${normalizedPath}, ${(error as Error).message}`); console.error(`[FileWatcher] 新增文件解析或入库失败: ${normalizedPath}, ${(error as Error).message}`); } } private logAddition(path: string): void { const message = `[FileWatcher] 检测到新文件添加并入库: ${path}`; Logger.info(TAG, message); console.info(message); } }