Sfoglia il codice sorgente

修复标签获取显示

chendeben 9 mesi fa
parent
commit
0d78aee2bb

+ 43 - 0
entry/src/main/ets/common/network/RemoteSongCache.ets

@@ -4,6 +4,7 @@ import FileManager, { merge2paths } from '../util/FileManager';
 import Logger from '../util/Logger';
 import { fileIo } from '@kit.CoreFileKit';
 import { common } from '@kit.AbilityKit';
+import { FileUtil } from '@pura/harmony-utils';
 
 const TAG = 'RemoteSongCache';
 const CACHE_ROOT_DIR = 'remote_cache';
@@ -38,6 +39,24 @@ async function ensureCacheRoot(): Promise<string> {
   return cacheRoot;
 }
 
+async function getCacheRootPath(): Promise<string> {
+  return ensureCacheRoot();
+}
+
+async function deletePathIfExists(path: string): Promise<void> {
+  if (!path) {
+    return;
+  }
+  try {
+    if (FileUtil.accessSync(path)) {
+      await FileUtil.rmdir(path);
+      Logger.info(TAG, `已删除缓存目录: ${path}`);
+    }
+  } catch (error) {
+    Logger.error(TAG, `删除缓存目录失败 ${path}: ${(error as Error).message}`);
+  }
+}
+
 async function ensureTypeDir(type: RemoteCacheType): Promise<string> {
   const root = await ensureCacheRoot();
   const typeDir = merge2paths(root, type);
@@ -120,3 +139,27 @@ export async function writeBufferToFile(data: ArrayBuffer, filePath: string): Pr
     throw err;
   }
 }
+
+export async function clearRemoteCacheByAccount(
+  type: RemoteCacheType,
+  accountId?: string | number
+): Promise<void> {
+  const root = await getCacheRootPath();
+  const typeDir = merge2paths(root, type);
+  const targetDir = accountId !== undefined ? merge2paths(typeDir, accountId.toString()) : typeDir;
+  await deletePathIfExists(targetDir);
+  await FileManager.createDir(accountId !== undefined ? targetDir : typeDir);
+}
+
+export async function clearAllRemoteCaches(): Promise<void> {
+  await clearRemoteCacheByAccount(RemoteCacheType.WEBDAV);
+  await clearRemoteCacheByAccount(RemoteCacheType.SMB);
+}
+
+export async function clearWebDavCacheByAccount(accountId?: string | number): Promise<void> {
+  await clearRemoteCacheByAccount(RemoteCacheType.WEBDAV, accountId);
+}
+
+export async function clearWebDavCaches(): Promise<void> {
+  await clearWebDavCacheByAccount();
+}

+ 82 - 21
entry/src/main/ets/common/util/MediaTable.ets

@@ -64,6 +64,22 @@ const DB_COLUMNS: DBColumnsInterface = {
   REMOTE_REL_PATH: 'remote_rel_path'
 };
 
+/**
+ * 统一的 filePath 规范化方法:
+ * - WebDAV 的 HTTP/HTTPS 地址转为相对路径,便于与数据库匹配
+ * - 其他路径保持不变
+ */
+function normalizeFilePath(filePath: string): string {
+  if (!filePath) {
+    return filePath;
+  }
+  if (WebDavUrlUtil.isHttpUrl(filePath)) {
+    const relative = WebDavUrlUtil.toStoragePath(filePath);
+    return relative || filePath;
+  }
+  return filePath;
+}
+
 export default  class MediaTable {
   private accountTable = new RdbUtils(RdbUtils.MEDIA_TABLE.tableName, RdbUtils.MEDIA_TABLE.sqlCreate,
     RdbUtils.MEDIA_TABLE.columns);
@@ -77,10 +93,11 @@ export default  class MediaTable {
    * 判断指定 filePath 是否已存在
    */
   private async existsByFilePath(filePath: string): Promise<boolean> {
+    const normalizedPath = normalizeFilePath(filePath);
     return new Promise<boolean>((resolve, reject) => {
       try {
         const predicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
-        predicates.equalTo(DB_COLUMNS.FILE_PATH, filePath);
+        predicates.equalTo(DB_COLUMNS.FILE_PATH, normalizedPath);
         this.accountTable.query(predicates, (resultSet: relationalStore.ResultSet) => {
           try {
             resolve(resultSet.rowCount > 0);
@@ -115,6 +132,7 @@ export default  class MediaTable {
           item.remote_rel_path = relativePath;
           // 更新filePath为相对路径
           item.filePath = relativePath;
+        item.dbFilePath = relativePath;
           Logger.info(RdbUtils.RDB_TAG, `WebDAV URL转换: 原始URL -> 存储路径: ${relativePath}`);
         }
       }
@@ -160,6 +178,38 @@ export default  class MediaTable {
     });
   }
 
+  /**
+   * 保存或更新WebDAV歌曲的完整元数据
+   */
+  public async saveOrUpdateWebDavItem(item: VideoItem): Promise<boolean> {
+    try {
+      if (!item || !item.filePath) {
+        return false;
+      }
+      item.filePath = normalizeFilePath(item.filePath);
+      if (!item.remote_rel_path) {
+        item.remote_rel_path = item.filePath;
+      }
+      if (!item.parentPath) {
+        const idx = item.filePath.lastIndexOf('/');
+        item.parentPath = idx > 0 ? item.filePath.substring(0, idx) : '';
+      }
+      const exists = await this.existsByFilePath(item.filePath);
+      if (exists) {
+        return await new Promise<boolean>((resolve) => {
+          const predicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
+          predicates.equalTo(DB_COLUMNS.FILE_PATH, item.filePath);
+          const bucket = generateBucket(item);
+          this.accountTable.updateData(predicates, bucket, (success: boolean) => resolve(success));
+        });
+      }
+      return await this.insertWebDavItem(item);
+    } catch (error) {
+      Logger.error(RdbUtils.RDB_TAG, 'saveOrUpdateWebDavItem 失败: ' + (error as Error).message);
+      return false;
+    }
+  }
+
   getRdbStore(context:Context,callback: Function = () => {
   }) {
     this.accountTable.getRdbStore(context,callback);
@@ -178,8 +228,9 @@ export default  class MediaTable {
   }
 
   deleteDataFilePath(filePath: string, callback: Function) {
+    const normalizedPath = normalizeFilePath(filePath);
     let predicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
-    predicates.equalTo('filePath', filePath);
+    predicates.equalTo('filePath', normalizedPath);
     this.accountTable.deleteData(predicates, callback);
   }
 
@@ -191,9 +242,10 @@ export default  class MediaTable {
 
   //更新音乐封面地址
   public updatePixelMapPath(filePath: string, newPixelMapPath: string, callback: Function) {
+    const normalizedPath = normalizeFilePath(filePath);
     // Step 1: 构建查询条件验证文件存在性
     const queryPredicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
-    queryPredicates.equalTo('filePath',  filePath);
+    queryPredicates.equalTo('filePath',  normalizedPath);
 
     // Step 2: 执行存在性验证
     this.accountTable.query(queryPredicates,  (resultSet: relationalStore.ResultSet) => {
@@ -206,7 +258,7 @@ export default  class MediaTable {
 
       // Step 3: 构建更新条件与数据
       const updatePredicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
-      updatePredicates.equalTo('filePath',  filePath);
+      updatePredicates.equalTo('filePath',  normalizedPath);
       const valueBucket: relationalStore.ValuesBucket = {
         pixelMapPath: newPixelMapPath
       };
@@ -241,21 +293,22 @@ export default  class MediaTable {
       callback(false);
       return false;
     }
-    console.info('onecold filePath updateMediaInfo= '+filePath)
+    const normalizedPath = normalizeFilePath(filePath);
+    console.info('onecold filePath updateMediaInfo= '+normalizedPath)
     // Step 1: Create a predicate to find the record by filePath
     const predicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
-    predicates.equalTo('filePath', filePath);
+    predicates.equalTo('filePath', normalizedPath);
 
     // Step 2: Query the database to check if the record exists
     this.accountTable.query(predicates, (resultSet: relationalStore.ResultSet) => {
       if (resultSet.rowCount === 0) {
-        Logger.info(RdbUtils.RDB_TAG, `No record found with filePath ${filePath}.`);
-        console.info('onecold No record found with filePath '+filePath)
+        Logger.info(RdbUtils.RDB_TAG, `No record found with filePath ${normalizedPath}.`);
+        console.info('onecold No record found with filePath '+normalizedPath)
         callback(false);
         resultSet.close();
         return;
       }
-      console.info('onecold filePath updateMediaInfo1= '+filePath)
+      console.info('onecold filePath updateMediaInfo1= '+normalizedPath)
       // Step 3: Prepare the values to update
       const valuesToUpdate: relationalStore.ValuesBucket = {};
       if (title !== '') {
@@ -314,9 +367,11 @@ export default  class MediaTable {
 
   //更新重命名数据操作
   public updateRename(newName: string, oldPath: string, newPath: string, callback: Function) {
+    const normalizedOldPath = normalizeFilePath(oldPath);
+    const normalizedNewPath = normalizeFilePath(newPath);
     // Step 1: 查询原始记录
     const queryPredicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
-    queryPredicates.equalTo('filePath',  oldPath);
+    queryPredicates.equalTo('filePath',  normalizedOldPath);
 
     this.accountTable.query(queryPredicates,  (resultSet: relationalStore.ResultSet) => {
       if (resultSet.rowCount  === 0) {
@@ -331,8 +386,8 @@ export default  class MediaTable {
       const currentFileName = resultSet.getString(resultSet.getColumnIndex('fileName'));
 
       let obj: relationalStore.ValuesBucket = {};
-      obj.id = newPath
-      obj.filePath = newPath;
+      obj.id = normalizedNewPath
+      obj.filePath = normalizedNewPath;
       if (currentName === currentFileName) {
         obj.name = newName;
         obj.fileName = newName;
@@ -382,7 +437,7 @@ export default  class MediaTable {
       const valueBucket: relationalStore.ValuesBucket = obj
       // Step 4: 执行更新
       const updatePredicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
-      updatePredicates.equalTo('filePath',  oldPath);
+      updatePredicates.equalTo('filePath',  normalizedOldPath);
 
       this.accountTable.updateData(updatePredicates,  valueBucket, (success: boolean) => {
         callback(success, success ? null : 'Update failed');
@@ -404,8 +459,9 @@ export default  class MediaTable {
 
   // 根据filePath更新isFav的值
   public updateIsFavByFilePath(filePath: string, isFav: number, callback: (success: boolean, error?: string) => void) {
+    const normalizedPath = normalizeFilePath(filePath);
     const predicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
-    predicates.equalTo('filePath', filePath);
+    predicates.equalTo('filePath', normalizedPath);
 
     this.accountTable.query(predicates, (resultSet: relationalStore.ResultSet) => {
       if (resultSet.rowCount === 0) {
@@ -579,8 +635,9 @@ export default  class MediaTable {
 
   // 根据filePath更新lastPlayedStr的值同时playCount值加1
   public updateLastPlayedStrByFilePath(filePath: string, lastPlayedStr: string, callback: (success: boolean, error?: string) => void) {
+    const normalizedPath = normalizeFilePath(filePath);
     const predicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
-    predicates.equalTo('filePath',   filePath);
+    predicates.equalTo('filePath',   normalizedPath);
 
     this.accountTable.query(predicates,   (resultSet: relationalStore.ResultSet) => {
       if (resultSet.rowCount   === 0) {
@@ -798,16 +855,17 @@ export default  class MediaTable {
    * @returns Promise that resolves with the lyric content (string) or null if not found
    */
   public getLyricContentByFilePath(filePath: string): Promise<string | null> {
+    const normalizedPath = normalizeFilePath(filePath);
     return new Promise((resolve, reject) => {
       // Create query predicates
       const predicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
-      predicates.equalTo(DB_COLUMNS.FILE_PATH,  filePath);
+      predicates.equalTo(DB_COLUMNS.FILE_PATH,  normalizedPath);
 
       // Execute the query
       this.accountTable.query(predicates,  (resultSet: relationalStore.ResultSet) => {
         try {
           if (resultSet.rowCount  === 0) {
-            Logger.info(RdbUtils.RDB_TAG,  `No record found for filePath: ${filePath}`);
+            Logger.info(RdbUtils.RDB_TAG,  `No record found for filePath: ${normalizedPath}`);
             resolve(null);
             return;
           }
@@ -843,11 +901,12 @@ export default  class MediaTable {
    * @returns Promise that resolves to true if record exists, false otherwise
    */
   public isRecordExists(filePath: string): Promise<boolean> {
+    const normalizedPath = normalizeFilePath(filePath);
     return new Promise((resolve, reject) => {
       try {
         // Create query predicates
         const predicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
-        predicates.equalTo(DB_COLUMNS.FILE_PATH,  filePath);
+        predicates.equalTo(DB_COLUMNS.FILE_PATH,  normalizedPath);
 
         // Execute the query
         this.accountTable.query(predicates,  (resultSet: relationalStore.ResultSet) => {
@@ -875,17 +934,18 @@ export default  class MediaTable {
    * @returns Promise that resolves with the VideoItem or null if not found
    */
   public queryVideoByFilePath(filePath: string): Promise<VideoItem | null> {
+    const normalizedPath = normalizeFilePath(filePath);
     return new Promise((resolve, reject) => {
       try {
         // Create query predicates
         const predicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
-        predicates.equalTo(DB_COLUMNS.FILE_PATH, filePath);
+        predicates.equalTo(DB_COLUMNS.FILE_PATH, normalizedPath);
 
         // Execute the query
         this.accountTable.query(predicates, (resultSet: relationalStore.ResultSet) => {
           try {
             if (resultSet.rowCount === 0) {
-              Logger.info(RdbUtils.RDB_TAG, `No record found for filePath: ${filePath}`);
+              Logger.info(RdbUtils.RDB_TAG, `No record found for filePath: ${normalizedPath}`);
               resolve(null);
               return;
             }
@@ -976,7 +1036,8 @@ function generateBucket(item: VideoItem): relationalStore.ValuesBucket {
   let obj: relationalStore.ValuesBucket = {};
   obj.id = item.id
   obj.name = item.name;
-  obj.filePath = item.filePath;
+  const normalizedPath = normalizeFilePath(item.filePath);
+  obj.filePath = normalizedPath;
   obj.mtype = item.type;
   obj.videoSize = item.videoSize;
   obj.cTime = item.cTime;

+ 97 - 0
entry/src/main/ets/common/util/RemoteDriveManager.ets

@@ -19,6 +19,7 @@ import { RemoteDriveType } from '../enums/RemoteDriveType';
 import { listSmbDirectory, SmbDirectoryEntry } from '../network/SmbBridge';
 import { NavidromeApi, NavidromeAlbumDetail, NavidromeArtist, NavidromeArtistDetail, NavidromeSong } from '../network/NavidromeApi';
 import { WebDavUrlUtil } from './WebDavUrlUtil';
+import MediaTable from './MediaTable';
 
 const TAG = 'heanup RemoteDriveManager';
 
@@ -827,6 +828,7 @@ export class RemoteDriveManager {
         this.webDavSongs.push(this.fileInfoToVideoItem(file, account));
       }
     }
+    await this.enrichSongsWithDatabase(this.webDavSongs);
     Logger.info(TAG, `从WebDAV获取到 ${files.length} 个文件/文件夹`);
   }
 
@@ -856,6 +858,7 @@ export class RemoteDriveManager {
         this.webDavSongs.push(this.fileInfoToVideoItem(fileInfo, account));
       }
     }
+    await this.enrichSongsWithDatabase(this.webDavSongs);
     Logger.info(TAG, `从SMB获取到 ${entries.length} 个文件/文件夹`);
   }
 
@@ -897,6 +900,7 @@ export class RemoteDriveManager {
       this.registerPathLabel(`/album/${albumId}`, detail.name);
       this.webDavFiles = detail.songs.map(song => this.createNavSongFileInfo(song, albumId));
       this.webDavSongs = detail.songs.map(song => this.buildNavidromeVideoItem(song, account, detail));
+      await this.enrichSongsWithDatabase(this.webDavSongs);
       Logger.info(TAG, `Navidrome 专辑 ${detail.name} 包含 ${detail.songs.length} 首歌曲`);
       return;
     }
@@ -904,6 +908,96 @@ export class RemoteDriveManager {
     throw new Error(`不支持的Navidrome路径: ${fullPath}`);
   }
 
+  private async enrichSongsWithDatabase(videoItems: VideoItem[]): Promise<void> {
+    if (!this.context || !videoItems || videoItems.length === 0) {
+      return;
+    }
+    try {
+      const mediaTable = new MediaTable(this.context);
+      await new Promise<void>((resolve) => {
+        mediaTable.getRdbStore(this.context as common.Context, () => resolve());
+      });
+      const pathMap: Map<string, VideoItem[]> = new Map();
+      for (let i = 0; i < videoItems.length; i++) {
+        const item = videoItems[i];
+        const storagePath = this.resolveStoragePathForItem(item);
+        if (!storagePath) {
+          continue;
+        }
+        if (!pathMap.has(storagePath)) {
+          pathMap.set(storagePath, []);
+        }
+        pathMap.get(storagePath)?.push(item);
+      }
+      if (pathMap.size === 0) {
+        return;
+      }
+      const tasks: Promise<void>[] = [];
+      pathMap.forEach((targets, storagePath) => {
+        tasks.push((async () => {
+          try {
+            const cached = await mediaTable.queryVideoByFilePath(storagePath);
+            if (cached) {
+              targets.forEach(target => this.mergeCachedMetadata(target, cached, storagePath));
+            }
+          } catch (error) {
+            Logger.error(TAG, `查询缓存元数据失败: ${storagePath} - ${(error as Error).message}`);
+          }
+        })());
+      });
+      await Promise.all(tasks);
+    } catch (error) {
+      Logger.error(TAG, `补全云端歌曲元数据失败: ${(error as Error).message}`);
+    }
+  }
+
+  private resolveStoragePathForItem(item: VideoItem): string | null {
+    if (!item) {
+      return null;
+    }
+    if (item.type === CommonConstants.TYPE_WEBDAV) {
+      const relative = item.remote_rel_path || WebDavUrlUtil.toStoragePath(item.filePath);
+      if (relative) {
+        item.remote_rel_path = relative;
+        item.dbFilePath = relative;
+        return relative;
+      }
+      return null;
+    }
+    if (item.type === CommonConstants.TYPE_SMB || item.type === CommonConstants.TYPE_NAVIDROME) {
+      if (item.remote_rel_path) {
+        item.dbFilePath = item.remote_rel_path;
+        return item.remote_rel_path;
+      }
+      return null;
+    }
+    return null;
+  }
+
+  private mergeCachedMetadata(target: VideoItem, cached: VideoItem, storagePath: string): void {
+    target.id = cached.id || target.id || storagePath;
+    target.name = cached.name || target.name;
+    target.artist = cached.artist || target.artist;
+    target.album = cached.album || target.album;
+    target.duration = cached.duration || target.duration;
+    target.size = cached.size || target.size;
+    target.pixelMapPath = cached.pixelMapPath || target.pixelMapPath;
+    target.pixelMap = cached.pixelMap || target.pixelMap;
+    target.lyricContent = cached.lyricContent || target.lyricContent;
+    target.md5Str = cached.md5Str || target.md5Str;
+    target.bit_rate = cached.bit_rate || target.bit_rate;
+    target.sampleRate = cached.sampleRate || target.sampleRate;
+    target.trackCount = cached.trackCount || target.trackCount;
+    target.mimeType = cached.mimeType || target.mimeType;
+    target.pyStr = cached.pyStr || target.pyStr;
+    target.genre = cached.genre || target.genre;
+    target.track = cached.track || target.track;
+    target.year = cached.year || target.year;
+    target.webdav_account_id = target.webdav_account_id || cached.webdav_account_id;
+    target.remote_rel_path = target.remote_rel_path || cached.remote_rel_path || storagePath;
+    target.dbFilePath = storagePath;
+  }
+
   // 将FileInfo转换为VideoItem
   private fileInfoToVideoItem(fileInfo: FileInfo, account: WebDavAccount): VideoItem {
     if (account.webType === RemoteDriveType.Smb) {
@@ -954,6 +1048,7 @@ export class RemoteDriveManager {
       const relMatch = absoluteUrl.match(/^https?:\/\/[^\/]+(?::\d+)?(\/.*)$/i);
       if (relMatch && relMatch[1]) {
         videoItem.remote_rel_path = relMatch[1];
+        videoItem.dbFilePath = relMatch[1];
       }
     } catch (e) {
       Logger.warn(TAG, '计算相对路径失败: ' + (e as Error).message);
@@ -986,6 +1081,7 @@ export class RemoteDriveManager {
       videoItem.webdav_account_id = account.id.toString();
     }
     videoItem.remote_rel_path = sanitizedRelative;
+    videoItem.dbFilePath = sanitizedRelative;
     const hostForDisplay = account.host && account.host.trim().length > 0
       ? account.host.trim()
       : (account.id?.toString() ?? '');
@@ -1076,6 +1172,7 @@ export class RemoteDriveManager {
     videoItem.size = Utility.formatFSize(song.size ?? 0);
     videoItem.webdav_account_id = account.id?.toString();
     videoItem.remote_rel_path = song.id;
+    videoItem.dbFilePath = song.id;
     videoItem.bit_rate = song.bitRate ? song.bitRate.toString() : undefined;
     videoItem.mimeType = song.contentType;
     return videoItem;

+ 3 - 0
entry/src/main/ets/common/util/Utility.ets

@@ -27,6 +27,7 @@ import { FFmpeg, FFProgressMessageParser } from '@sj/ffmpeg';
 import ReqPermissionUtil from './ReqPermissionUtil';
 import { commentManager } from '@kit.AppGalleryKit';
 import { hilog } from '@kit.PerformanceAnalysisKit';
+import Logger from './Logger';
 
 
 export interface FFMpegTags {
@@ -903,6 +904,7 @@ export class Utility {
             let artist = tags.artist ||tags.ARTIST || '';
             let title = tags.title ||tags.TITLE || '';
             let album = tags.album ||tags.ALBUM|| '';
+            Logger.info('readMetaInfoFFmpeg', `原始标签 title="${title}", artist="${artist}", album="${album}"`);
             console.log(`onecold tags.tags:${JSON.stringify(tags)}`);
             // 检查是否有乱码
             if (hasGarbledText(tags))  {
@@ -915,6 +917,7 @@ export class Utility {
               console.info('onecold 乱码修正 title='+title);
               console.info('onecold 乱码修正 album='+album);
             }
+            Logger.info('readMetaInfoFFmpeg', `最终标签 title="${title}", artist="${artist}", album="${album}", autoParse=${autoParseMusicName}`);
             if(StrUtil.isEmpty(title)){
               //自动解析像这样的获取不到元数据的 周杰伦-七里香.mp3文件
               console.log(`onecold musicName为空:${file.name}`);

+ 46 - 0
entry/src/main/ets/pages/SettingPage.ets

@@ -20,6 +20,8 @@ import { FastForwardSecondInterface } from './FastForwardSecondInterface'
 import { ButtonFancyModifier, ShadowModifier, SymbolGlyphFancyModifier } from '../common/util/AttributeModifierUtil'
 import { CustomizeICON, Icon } from '../view/CustomizeICON'
 import { appInfoManager } from '@kit.StoreKit'
+import { clearWebDavCacheByAccount, clearWebDavCaches } from '../common/network/RemoteSongCache';
+import { RemoteDriveManager } from '../common/util/RemoteDriveManager';
 
 @Preview
 // @Entry
@@ -35,6 +37,7 @@ export struct SettingPage {
   static readonly IS_COPYFILE_TO_DOWNLOAD: string = 'isCopyFileToDownLoad';
   @State fastForwardSeconds: string = '10'
   @State isShowBackFast: boolean = true//快进快退按钮
+  @State isClearingCache: boolean = false
   @Consume isShowDrawer: boolean;
   @Consume offsetX: number;
   @Consume mType: number;
@@ -337,6 +340,26 @@ export struct SettingPage {
     context.getApplicationContext().setColorMode(colorMode)
   }
 
+  private handleClearWebDavCache() {
+    if (this.isClearingCache) {
+      return;
+    }
+    this.isClearingCache = true;
+    const manager = RemoteDriveManager.getInstance();
+    const accountId = manager?.currentAccount?.id;
+    const clearPromise = accountId !== undefined
+      ? clearWebDavCacheByAccount(accountId)
+      : clearWebDavCaches();
+    clearPromise.then(() => {
+      ToastUtil.showToast('已清除网盘缓存');
+    }).catch((error: Error) => {
+      hilog.error(0, 'SettingPage', `清除网盘缓存失败: ${error.message}`);
+      ToastUtil.showToast('清除失败,请稍后重试');
+    }).finally(() => {
+      this.isClearingCache = false;
+    });
+  }
+
   @Builder
   pickerBuilder() {
     Column({ space: 20 }) {
@@ -1590,6 +1613,29 @@ export struct SettingPage {
             .height(55)
             .clickEffect({ level: ClickEffectLevel.HEAVY })
             Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
+            Row() {
+              SymbolGlyph($r('sys.symbol.trash'))
+                .fontSize(20)
+                .fontColor([this.themeColor])
+                .alignSelf(ItemAlign.Center)
+                .margin({ left: 15 })
+              Text('清除网盘缓存')
+                .margin({ left: 8 })
+                .fontSize(15)
+                .fontColor(Color.Gray)
+                .fontWeight(480)
+                .layoutWeight(1)
+              Text(this.isClearingCache ? '清理中…' : '立即清理')
+                .margin({ right: 18 })
+                .fontSize(13)
+                .fontColor(this.themeColor)
+            }
+            .height(55)
+            .clickEffect({ level: ClickEffectLevel.HEAVY })
+            .onClick(() => {
+              this.handleClearWebDavCache();
+            })
+            Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
             // 长歌名滚动
             Row() {
               SymbolGlyph($r('sys.symbol.close_sidebar'))

+ 20 - 13
entry/src/main/ets/pages/WebDavMainPage.ets

@@ -161,6 +161,22 @@ export struct WebDavMainPage {
     }
   }
 
+  private buildSongMetaLine(song: VideoItem): string {
+    const parts: string[] = [];
+    if (StrUtil.isNotEmpty(song.album)) {
+      parts.push(song.album as string);
+    }
+    if (StrUtil.isNotEmpty(song.duration)) {
+      parts.push(song.duration as string);
+    } else if (StrUtil.isNotEmpty(song.size)) {
+      parts.push(decodeUrlEncodedString(song.size as string));
+    }
+    if (parts.length === 0 && StrUtil.isNotEmpty(song.cTime)) {
+      parts.push(song.cTime as string);
+    }
+    return parts.join(' · ');
+  }
+
   // 对话框控制器
   private accountDialogController: CustomDialogController | null = null;
   // 保存事件处理器引用,用于取消订阅
@@ -1017,12 +1033,12 @@ export struct WebDavMainPage {
       Row({ space: 12 }) {
         // 序号
         // 歌曲封面
-        Image(song.pixelMap)
+        Image(song.pixelMapPath ? song.pixelMapPath : (song.pixelMap ?? $r('app.media.music_red')))
           .width(48)
           .height(48)
           .borderRadius(4)
           .alt($r('app.media.music_red'))
-          .fillColor(this.themeColor)
+          .fillColor(song.pixelMapPath ? undefined : this.themeColor)
           .objectFit(ImageFit.Cover)
           .margin({ left: 8 })
 
@@ -1036,28 +1052,19 @@ export struct WebDavMainPage {
             .textOverflow({ overflow: TextOverflow.Ellipsis })
 
           Row(){
-            Text(song.artist+"  ")
+            Text((song.artist ?? '') + "  ")
               .fontSize(13)
               .fontColor(this.currentSong?.filePath==song.filePath?this.themeColor:$r('app.color.index_tab_font_color'))
               .opacity(0.6)
               .maxLines(1)
               .visibility(song.artist?Visibility.Visible:Visibility.None)
               .textOverflow({ overflow: TextOverflow.Ellipsis })
-            Text(decodeUrlEncodedString(song.size||""))
-              .fontSize(13)
-              .fontColor(this.currentSong?.filePath==song.filePath?this.themeColor:
-                $r('app.color.index_tab_font_color'))
-              .opacity(0.6)
-              .maxLines(1)
-              .textOverflow({ overflow: TextOverflow.Ellipsis })
-            Text(song.cTime)
+            Text(this.buildSongMetaLine(song))
               .fontSize(13)
               .fontColor(this.currentSong?.filePath==song.filePath?this.themeColor:
                 $r('app.color.index_tab_font_color'))
               .opacity(0.6)
               .maxLines(1)
-              .padding({left: 10})
-              .visibility(StrUtil.isNotEmpty(song.artist)?Visibility.None:Visibility.Visible)
               .textOverflow({ overflow: TextOverflow.Ellipsis })
           }
           .width('90%')

+ 207 - 33
entry/src/main/ets/view/LocalMusic.ets

@@ -254,6 +254,22 @@ interface WorkerMetadataPayload {
   artist?: string;
   album?: string;
   name?: string;
+  lyricContent?: string;
+  bits_per_raw_sample?: string;
+  channels?: string;
+  channel_layout?: string;
+  start_time?: string;
+  genre?: string;
+  year?: string;
+  probe_score?: number;
+  track?: string;
+  disc?: string;
+  ALBUMARTIST?: string;
+  COMPOSER?: string;
+  COMMENT?: string;
+  LYRICIST?: string;
+  nb_streams?: number;
+  nb_programs?: number;
   error?: string;
 }
 
@@ -358,6 +374,7 @@ function cloneVideoItem(item: VideoItem): VideoItem {
   copy.disc = item.disc;
   copy.webdav_account_id = item.webdav_account_id;
   copy.remote_rel_path = item.remote_rel_path;
+  copy.dbFilePath = item.dbFilePath;
   return copy;
 }
 
@@ -1420,6 +1437,8 @@ export struct LocalMusic {
     collectFromList(this.videoLocalList);
     collectFromList(this.historyList);
     collectFromList(this.favList);
+    const remoteManager = RemoteDriveManager.getInstance();
+    collectFromList(remoteManager.webDavSongs);
 
     const updated = new Set<VideoItem>();
     for (let i = 0; i < targets.length; i++) {
@@ -1428,42 +1447,11 @@ export struct LocalMusic {
         continue;
       }
       updated.add(target);
-      if (payload.name) {
-        target.name = payload.name;
-      }
-      if (payload.artist) {
-        target.artist = payload.artist;
-      }
-      if (payload.album) {
-        target.album = payload.album;
-      }
-      if (payload.bit_rate) {
-        target.bit_rate = payload.bit_rate;
-      }
-      if (payload.duration) {
-        target.duration = payload.duration;
-      }
-      if (payload.sampleRate) {
-        target.sampleRate = payload.sampleRate;
-      }
-      if (payload.mimeType) {
-        target.mimeType = payload.mimeType;
-      }
-      if (payload.md5Str) {
-        target.md5Str = payload.md5Str;
-      }
-      if (payload.videoSize !== undefined) {
-        target.videoSize = payload.videoSize;
-      }
-      if (payload.size) {
-        target.size = payload.size;
-      }
-      if (payload.pixelMapPath) {
-        target.pixelMapPath = payload.pixelMapPath;
-      }
+      this.mergeVideoItemWithPayload(target, payload);
     }
 
     if (this.currentSong && this.currentSong.filePath === filePath) {
+      this.mergeVideoItemWithPayload(this.currentSong, payload);
       if (payload.name) {
         this.name = payload.name;
       }
@@ -1473,9 +1461,193 @@ export struct LocalMusic {
       if (payload.pixelMapPath) {
         this.cover = payload.pixelMapPath;
       }
+      if (payload.lyricContent) {
+        this.applyInlineLyricContent(payload.lyricContent);
+      }
       this.currentSong = cloneVideoItem(this.currentSong);
       AppStorage.setOrCreate('currentSong', this.currentSong);
     }
+
+    const primaryTarget = targets.length > 0 ? targets[0] : undefined;
+    if (primaryTarget) {
+      this.persistRemoteMetadataToDb(primaryTarget).catch((error: Error) => {
+        Logger.error(TAG, `缓存元数据持久化失败: ${error.message}`);
+      });
+    }
+  }
+
+  private assignStringMetadata(target: VideoItem, key: keyof VideoItem, value?: string | null, allowEmpty = false): void {
+    if (value === undefined || value === null) {
+      return;
+    }
+    const text = value.toString();
+    if (!allowEmpty && text.trim().length === 0) {
+      return;
+    }
+    Reflect.set(target, key as string, value);
+  }
+
+  private assignNumberMetadata(target: VideoItem, key: keyof VideoItem, value?: number | null): void {
+    if (value === undefined || value === null || Number.isNaN(value)) {
+      return;
+    }
+    Reflect.set(target, key as string, value);
+  }
+
+  private mergeVideoItemWithPayload(target: VideoItem, payload: WorkerMetadataPayload): void {
+    if (!target || !payload) {
+      return;
+    }
+    this.assignStringMetadata(target, 'name', payload.name);
+    this.assignStringMetadata(target, 'artist', payload.artist);
+    this.assignStringMetadata(target, 'album', payload.album);
+    this.assignStringMetadata(target, 'bit_rate', payload.bit_rate);
+    this.assignStringMetadata(target, 'duration', payload.duration);
+    this.assignStringMetadata(target, 'sampleRate', payload.sampleRate);
+    this.assignStringMetadata(target, 'mimeType', payload.mimeType);
+    this.assignStringMetadata(target, 'size', payload.size);
+    this.assignStringMetadata(target, 'pixelMapPath', payload.pixelMapPath);
+    this.assignStringMetadata(target, 'lyricContent', payload.lyricContent, true);
+    this.assignStringMetadata(target, 'genre', payload.genre);
+    this.assignStringMetadata(target, 'track', payload.track);
+    this.assignStringMetadata(target, 'bits_per_raw_sample', payload.bits_per_raw_sample);
+    this.assignStringMetadata(target, 'channels', payload.channels);
+    this.assignStringMetadata(target, 'channel_layout', payload.channel_layout);
+    this.assignStringMetadata(target, 'start_time', payload.start_time);
+    this.assignStringMetadata(target, 'year', payload.year);
+    this.assignStringMetadata(target, 'disc', payload.disc);
+    this.assignStringMetadata(target, 'ALBUMARTIST', payload.ALBUMARTIST);
+    this.assignStringMetadata(target, 'COMPOSER', payload.COMPOSER);
+    this.assignStringMetadata(target, 'LYRICIST', payload.LYRICIST);
+    this.assignStringMetadata(target, 'COMMENT', payload.COMMENT, true);
+    this.assignNumberMetadata(target, 'videoSize', payload.videoSize);
+    this.assignNumberMetadata(target, 'probe_score', payload.probe_score);
+    this.assignNumberMetadata(target, 'nb_streams', payload.nb_streams);
+    this.assignNumberMetadata(target, 'nb_programs', payload.nb_programs);
+  }
+
+  private mergeVideoItemFromSource(target: VideoItem, source: VideoItem): void {
+    if (!target || !source) {
+      return;
+    }
+    this.assignStringMetadata(target, 'name', source.name);
+    this.assignStringMetadata(target, 'artist', source.artist);
+    this.assignStringMetadata(target, 'album', source.album);
+    this.assignStringMetadata(target, 'bit_rate', source.bit_rate);
+    this.assignStringMetadata(target, 'duration', source.duration);
+    this.assignStringMetadata(target, 'sampleRate', source.sampleRate);
+    this.assignStringMetadata(target, 'trackCount', source.trackCount);
+    this.assignStringMetadata(target, 'mimeType', source.mimeType);
+    this.assignStringMetadata(target, 'size', source.size);
+    this.assignStringMetadata(target, 'pixelMapPath', source.pixelMapPath);
+    this.assignStringMetadata(target, 'lyricContent', source.lyricContent, true);
+    this.assignStringMetadata(target, 'genre', source.genre);
+    this.assignStringMetadata(target, 'track', source.track);
+    this.assignStringMetadata(target, 'bits_per_raw_sample', source.bits_per_raw_sample);
+    this.assignStringMetadata(target, 'channels', source.channels);
+    this.assignStringMetadata(target, 'channel_layout', source.channel_layout);
+    this.assignStringMetadata(target, 'start_time', source.start_time);
+    this.assignStringMetadata(target, 'year', source.year);
+    this.assignStringMetadata(target, 'disc', source.disc);
+    this.assignStringMetadata(target, 'ALBUMARTIST', source.ALBUMARTIST);
+    this.assignStringMetadata(target, 'COMPOSER', source.COMPOSER);
+    this.assignStringMetadata(target, 'LYRICIST', source.LYRICIST);
+    this.assignStringMetadata(target, 'COMMENT', source.COMMENT, true);
+    this.assignNumberMetadata(target, 'videoSize', source.videoSize);
+    this.assignNumberMetadata(target, 'probe_score', source.probe_score);
+    this.assignNumberMetadata(target, 'nb_streams', source.nb_streams);
+    this.assignNumberMetadata(target, 'nb_programs', source.nb_programs);
+    if (source.pixelMap && !target.pixelMap) {
+      target.pixelMap = source.pixelMap;
+    }
+    if (source.remote_rel_path && StrUtil.isEmpty(target.remote_rel_path)) {
+      target.remote_rel_path = source.remote_rel_path;
+    }
+    if (source.dbFilePath) {
+      target.dbFilePath = source.dbFilePath;
+    }
+  }
+
+  private async hydrateRemoteSongFromDb(item: VideoItem | undefined): Promise<void> {
+    if (!item || !isRemoteCloudType(item.type)) {
+      return;
+    }
+    const storagePath = item.remote_rel_path || WebDavUrlUtil.toStoragePath(item.filePath);
+    if (!storagePath) {
+      return;
+    }
+    try {
+      const cached = await this.table.queryVideoByFilePath(storagePath);
+      if (!cached) {
+        return;
+      }
+      this.mergeVideoItemFromSource(item, cached);
+      if (StrUtil.isEmpty(item.remote_rel_path)) {
+        item.remote_rel_path = storagePath;
+      }
+      item.dbFilePath = storagePath;
+      if (item === this.currentSong) {
+        if (StrUtil.isNotEmpty(item.name)) {
+          this.name = item.name;
+        }
+        if (StrUtil.isNotEmpty(item.artist)) {
+          this.artist = item.artist!;
+        }
+        if (StrUtil.isNotEmpty(item.pixelMapPath)) {
+          this.cover = item.pixelMapPath!;
+        }
+        if (StrUtil.isNotEmpty(item.lyricContent) && StrUtil.isEmpty(this.lyricContent)) {
+          this.applyInlineLyricContent(item.lyricContent as string);
+        }
+      }
+    } catch (error) {
+      Logger.error(TAG, `hydrateRemoteSongFromDb 失败: ${(error as Error).message}`);
+    }
+  }
+
+  private applyInlineLyricContent(content: string): void {
+    if (StrUtil.isEmpty(content)) {
+      return;
+    }
+    this.lyricContent = content;
+    const lines = content.split('\n').map(line => line.trim());
+    const lyric = this.parser.parse(lines);
+    this.lyricController.setLyric(lyric);
+    this.lyricControllerXF.setLyric(lyric);
+    this.lyricControllerSingle.setLyric(lyric);
+  }
+
+  private extractParentPathFromStoragePath(path: string): string {
+    if (!path) {
+      return '';
+    }
+    const lastSlash = path.lastIndexOf('/');
+    if (lastSlash <= 0) {
+      return '';
+    }
+    return path.substring(0, lastSlash);
+  }
+
+  private async persistRemoteMetadataToDb(target: VideoItem): Promise<void> {
+    if (!this.context || !target || !isWebDavType(target.type)) {
+      return;
+    }
+    const storagePath = target.remote_rel_path || WebDavUrlUtil.toStoragePath(target.filePath);
+    if (!storagePath) {
+      return;
+    }
+    target.remote_rel_path = target.remote_rel_path || storagePath;
+    target.dbFilePath = storagePath;
+    const table = new MediaTable(this.context);
+    await new Promise<void>((resolve) => {
+      table.getRdbStore(this.context as Context, () => resolve());
+    });
+    const cloneItem = cloneVideoItem(target);
+    cloneItem.filePath = storagePath;
+    cloneItem.remote_rel_path = storagePath;
+    cloneItem.dbFilePath = storagePath;
+    cloneItem.parentPath = this.extractParentPathFromStoragePath(storagePath);
+    await table.saveOrUpdateWebDavItem(cloneItem);
   }
 
   onPageHide(): void {
@@ -6395,6 +6567,8 @@ export struct LocalMusic {
           this.sonDataSource.pushArrayData(this.songList)
         }
 
+        await this.hydrateRemoteSongFromDb(this.currentSong);
+        await this.hydrateRemoteSongFromDb(this.currentSong);
         AppStorage.setOrCreate('currentSong', this.currentSong);
 
         // 使用辅助方法设置videoUrl,等待WebDAV URL构建完成

+ 19 - 1
entry/src/main/ets/workers/Worker.ets

@@ -13,6 +13,7 @@ interface MetadataRequestPayload {
   originFilePath?: string;
   originId?: string;
   autoParseMusicName?: boolean;
+  trackCount?: string;
 }
 
 
@@ -303,6 +304,7 @@ async function analyzeCachedMediaMetadata(
         bit_rate: videoItem.bit_rate,
         duration: videoItem.duration,
         sampleRate: videoItem.sampleRate,
+        trackCount: videoItem.trackCount,
         mimeType: videoItem.mimeType,
         md5Str: videoItem.md5Str,
         videoSize: videoItem.videoSize,
@@ -310,7 +312,23 @@ async function analyzeCachedMediaMetadata(
         pixelMapPath: videoItem.pixelMapPath,
         artist: videoItem.artist,
         album: videoItem.album,
-        name: videoItem.name
+        name: videoItem.name,
+        lyricContent: videoItem.lyricContent,
+        bits_per_raw_sample: videoItem.bits_per_raw_sample,
+        channels: videoItem.channels,
+        channel_layout: videoItem.channel_layout,
+        start_time: videoItem.start_time,
+        genre: videoItem.genre,
+        year: videoItem.year,
+        probe_score: videoItem.probe_score,
+        track: videoItem.track,
+        disc: videoItem.disc,
+        ALBUMARTIST: videoItem.ALBUMARTIST,
+        COMPOSER: videoItem.COMPOSER,
+        COMMENT: videoItem.COMMENT,
+        LYRICIST: videoItem.LYRICIST,
+        nb_streams: videoItem.nb_streams,
+        nb_programs: videoItem.nb_programs
       }
     });
   } catch (error) {