Forráskód Böngészése

feat(music): add file deletion watcher for local music sync

chendeben 9 hónapja
szülő
commit
aa56ba470c

+ 360 - 0
entry/src/main/ets/common/util/FileDeletionWatcher.ets

@@ -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);
+  }
+}

+ 24 - 0
entry/src/main/ets/view/LocalMusic.ets

@@ -73,6 +73,7 @@ import { CommonConstants2 } from '../common/util/CommonConstants2';
 import { hilog } from '@kit.PerformanceAnalysisKit';
 import { LazyDataSource } from '../common/util/LazyDataSource';
 import  MediaTable  from '../common/util/MediaTable';
+import FileDeletionWatcher from '../common/util/FileDeletionWatcher';
 import NetAxiosUtil from '../common/util/NetAxiosUtil';
 import ImageUtils from '../common/util/ImageUtils';
 import { ringtone } from '@kit.RingtoneKit';
@@ -638,6 +639,7 @@ export struct LocalMusic {
   static readonly HISTORY_MUSIC: string = 'music_historyList';
   private table: MediaTable = new MediaTable(getContext(this))
   private playlistTable: PlaylistTable | null = null
+  private deletionWatcher: FileDeletionWatcher | null = null
   @State allPlaylists: Playlist[] = []
   @State isZero: boolean = false
   @State fileList: Array<string> = []
@@ -1763,6 +1765,25 @@ export struct LocalMusic {
 
   @State isFirstStartPlay: boolean = false
 
+  private async startFileDeletionWatcher(): Promise<void> {
+    if (!this.rootPath) {
+      return;
+    }
+    if (!this.deletionWatcher) {
+      this.deletionWatcher = new FileDeletionWatcher(this.context, this.table);
+    }
+    try {
+      await this.deletionWatcher.watchDirectories([
+        this.rootPath,
+        this.lockPath,
+        this.favPath,
+        this.historyPath
+      ]);
+    } catch (error) {
+      Logger.error(TAG, `初始化文件删除监听失败: ${(error as Error).message}`);
+    }
+  }
+
   //获取download_path
   async mkDownLoadDir() {
 
@@ -1788,6 +1809,8 @@ export struct LocalMusic {
       FileUtil.mkdirSync(this.historyPath)
     }
 
+    await this.startFileDeletionWatcher();
+
     this.getSortedFiles(this.rootPath).then(async () => {
       this.isFavMusic = false
       //穿山甲
@@ -1853,6 +1876,7 @@ export struct LocalMusic {
   // 组件消失生命周期
   aboutToDisappear() {
     console.info('LifeCycleComponent aboutToDisappear');
+    this.deletionWatcher?.stop();
     this.knockController?.immersiveDisableListening();
     this.curIndex = 0
     emitter.off(EventConstants.EVENT_AUDIO_OPEN);