|
|
@@ -0,0 +1,360 @@
|
|
|
+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<string, fileIo.Watcher> = new Map();
|
|
|
+ private dbReadyPromise?: Promise<void>;
|
|
|
+ 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<void> {
|
|
|
+ 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<void> {
|
|
|
+ if (!this.dbReadyPromise) {
|
|
|
+ this.dbReadyPromise = new Promise<void>(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<void> {
|
|
|
+ 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<void> {
|
|
|
+ await new Promise<void>((resolve) => {
|
|
|
+ this.mediaTable.queryByParentPath(dir, async (items: Array<VideoItem>) => {
|
|
|
+ for (let i = 0; i < items.length; i++) {
|
|
|
+ await this.removeIfMissing(items[i].filePath);
|
|
|
+ }
|
|
|
+ resolve();
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private async removeIfMissing(filePath?: string): Promise<void> {
|
|
|
+ if (!filePath) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ let exists = true;
|
|
|
+ try {
|
|
|
+ exists = FileUtil.accessSync(filePath);
|
|
|
+ } catch (_) {
|
|
|
+ exists = false;
|
|
|
+ }
|
|
|
+ if (exists) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await new Promise<void>((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<void> {
|
|
|
+ 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<void>((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);
|
|
|
+ }
|
|
|
+}
|