Explorar o código

网盘的webdav和百度 smb 获取音乐封面 无需下载就可以获取封面

onecold hai 5 meses
pai
achega
aa5c84be6c
Modificáronse 1 ficheiros con 418 adicións e 1 borrados
  1. 418 1
      entry/src/main/ets/pages/WebDavMainPage.ets

+ 418 - 1
entry/src/main/ets/pages/WebDavMainPage.ets

@@ -12,7 +12,7 @@ import { FileInfo } from '../viewmodel/FileInfo';
 import { emitter } from '@kit.BasicServicesKit';
 import { EventConstants } from '../common/constants/EventConstants';
 import { LazyDataSource } from '../common/util/LazyDataSource';
-import { ArrayUtil, PreferencesUtil, StrUtil, ToastUtil } from '@pura/harmony-utils';
+import { ArrayUtil, FileUtil, MD5, PreferencesUtil, StrUtil, ToastUtil } from '@pura/harmony-utils';
 import { DialogHelper } from '@pura/harmony-dialog';
 import { ButtonFancyModifier,
   MenuModifier,
@@ -24,6 +24,10 @@ import { SettingPage } from './SettingPage';
 import { getCloudDiskIcon } from '../dialog/RemoteDriveAccountDialog';
 import { CreateFolderDialog } from '../dialog/CreateFolderDialog';
 import { UploadMusicPage } from './UploadMusicPage';
+import { FFmpeg } from '@sj/ffmpeg';
+import FileManager from '../common/util/FileManager';
+import { fileIo, fileUri } from '@kit.CoreFileKit';
+import { setVideoUrlForSong } from '../common/util/RemotePlayerUtil';
 
 /**
  * 歌单播放事件数据
@@ -45,11 +49,20 @@ interface WebDavMetadataUpdatePayload {
   artist?: string;
 }
 
+interface RemoteThumbSource {
+  playUrl: string;
+  headers: Map<string, string>;
+}
+
 const TAG = 'heanup WebDavMainPage';
+const REMOTE_THUMB_CACHE_DIR: string = 'remote_thumbs';
+const REMOTE_THUMB_MAX_TASK_COUNT: number = 36;
+const REMOTE_THUMB_CAPTURE_SECONDS: string = '1.2';
 
 // WebDAV歌曲数据全局内存存储
 let globalWebdavVideoItems: VideoItem[] = [];
 let globalWebdavCurrentPlayIndex: number = 0;
+let globalRemoteThumbFfmpegQueue: Promise<void> = Promise.resolve();
 
 // 导出函数供LocalMusic访问
 export function getWebdavVideoItems(): VideoItem[] {
@@ -121,9 +134,23 @@ export struct WebDavMainPage {
   @State isAllSelected: boolean = false
   @State isDeletingSelection: boolean = false
   private readonly metadataUpdateEvent: emitter.InnerEvent = { eventId: EventConstants.EVENT_WEBDAV_METADATA_UPDATED };
+  private thumbnailTaskToken: number = 0;
+  private thumbnailRunningKeys: Set<string> = new Set<string>();
+
+  private async runSerializedThumbnailFfmpeg(task: () => Promise<void>): Promise<void> {
+    const nextTask: Promise<void> = globalRemoteThumbFfmpegQueue.then(async (): Promise<void> => {
+      await task();
+    }, async (): Promise<void> => {
+      await task();
+    });
+    globalRemoteThumbFfmpegQueue = nextTask.catch((): void => {});
+    await nextTask;
+  }
 
   async onSwitchAccount(){
     console.log('heanup 切换账户:', this.selectedAccount.name);
+    this.thumbnailTaskToken += 1;
+    this.thumbnailRunningKeys.clear();
     this.songs = [];
     this.visibleFoldersState = [];
     this.updateListData(this.songs)
@@ -157,6 +184,393 @@ export struct WebDavMainPage {
 
   }
 
+  private buildThumbnailIdentity(item: VideoItem): string {
+    const accountId: string = item.webdav_account_id ? item.webdav_account_id : '';
+    const relPath: string = item.remote_rel_path ? item.remote_rel_path : '';
+    return `${item.type}:${accountId}:${relPath}:${item.filePath}`;
+  }
+
+  private async ensureThumbnailCacheDir(): Promise<string> {
+    const context = getContext(this);
+    const cacheDir: string = `${context.filesDir}/${REMOTE_THUMB_CACHE_DIR}`;
+    await FileManager.createDir(cacheDir);
+    return cacheDir;
+  }
+
+  private async resolveRemoteThumbPath(item: VideoItem): Promise<string> {
+    const cacheDir: string = await this.ensureThumbnailCacheDir();
+    const identity: string = this.buildThumbnailIdentity(item);
+    const hashName: string = MD5.digestSync(identity);
+    return `${cacheDir}/${hashName}.jpg`;
+  }
+
+  private buildFfmpegHeadersArg(headers: Map<string, string>): string {
+    if (!headers || headers.size <= 0) {
+      return '';
+    }
+    const lines: string[] = [];
+    headers.forEach((value: string, key: string): void => {
+      if (StrUtil.isEmpty(key) || StrUtil.isEmpty(value)) {
+        return;
+      }
+      lines.push(`${key}: ${value}`);
+    });
+    if (lines.length <= 0) {
+      return '';
+    }
+    return `${lines.join('\r\n')}\r\n`;
+  }
+
+  private buildRemoteThumbFfmpegCmd(playUrl: string, headersArg: string, outputPath: string): string[] {
+    const commands: string[] = ['ffmpeg', '-y', '-ss', REMOTE_THUMB_CAPTURE_SECONDS];
+    const networkArgs: string[] = this.buildRemoteFfmpegNetworkArgs(playUrl);
+    for (let index: number = 0; index < networkArgs.length; index += 1) {
+      commands.push(networkArgs[index]);
+    }
+    if (StrUtil.isNotEmpty(headersArg)) {
+      commands.push('-headers', headersArg);
+    }
+    commands.push(
+      '-i', playUrl,
+      '-frames:v', '1',
+      '-vf', 'scale=320:-2',
+      '-q:v', '4',
+      outputPath
+    );
+    return commands;
+  }
+
+  private buildRemoteAudioCoverFfmpegCmd(playUrl: string, headersArg: string, outputPath: string): string[] {
+    const commands: string[] = ['ffmpeg', '-y'];
+    const networkArgs: string[] = this.buildRemoteFfmpegNetworkArgs(playUrl);
+    for (let index: number = 0; index < networkArgs.length; index += 1) {
+      commands.push(networkArgs[index]);
+    }
+    if (StrUtil.isNotEmpty(headersArg)) {
+      commands.push('-headers', headersArg);
+    }
+    commands.push(
+      '-i', playUrl,
+      '-map', '0:v:0',
+      '-frames:v', '1',
+      '-q:v', '4',
+      outputPath
+    );
+    return commands;
+  }
+
+  private buildRemoteFfmpegNetworkArgs(playUrl: string): string[] {
+    if (StrUtil.isEmpty(playUrl)) {
+      return [];
+    }
+    const lowerPlayUrl: string = playUrl.toLowerCase();
+    if (!lowerPlayUrl.startsWith('http://') && !lowerPlayUrl.startsWith('https://')) {
+      return [];
+    }
+    return ['-rw_timeout', '15000000', '-probesize', '1048576', '-analyzeduration', '2000000'];
+  }
+
+  private sanitizeFfmpegCommands(commands: string[]): string[] {
+    const safeCommands: string[] = [];
+    for (let index: number = 0; index < commands.length; index++) {
+      const value: string = commands[index];
+      if (value === undefined || value === null) {
+        Logger.warn(TAG, `缩略图命令存在空参数 index=${index}`);
+        continue;
+      }
+      const text: string = `${value}`;
+      if (text.length <= 0) {
+        Logger.warn(TAG, `缩略图命令存在空字符串参数 index=${index}`);
+        continue;
+      }
+      safeCommands.push(text);
+    }
+    return safeCommands;
+  }
+
+  private normalizeThumbImageSource(path: string): string {
+    if (StrUtil.isEmpty(path)) {
+      return '';
+    }
+    if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('file://')) {
+      return path;
+    }
+    if (path.startsWith('/')) {
+      return fileUri.getUriFromPath(path);
+    }
+    return path;
+  }
+
+  private normalizeThumbLocalPath(path: string): string {
+    if (StrUtil.isEmpty(path)) {
+      return '';
+    }
+    if (path.startsWith('file://')) {
+      try {
+        return new fileUri.FileUri(path).path;
+      } catch (error) {
+        const err: Error = error as Error;
+        Logger.warn(TAG, `缩略图URI转路径失败 uri=${path}, error=${err.message}`);
+        return '';
+      }
+    }
+    return path;
+  }
+
+  private isThumbCacheValid(localPath: string): boolean {
+    if (StrUtil.isEmpty(localPath)) {
+      return false;
+    }
+    if (!FileUtil.accessSync(localPath)) {
+      return false;
+    }
+    try {
+      const stat: fileIo.Stat = fileIo.statSync(localPath);
+      if (stat.size <= 1024) {
+        Logger.warn(TAG, `缩略图缓存文件过小,判定无效 path=${localPath}, size=${stat.size}`);
+        return false;
+      }
+      if (this.isImageMagicValid(localPath)) {
+        return true;
+      }
+      Logger.warn(TAG, `缩略图缓存文件头无效,判定无效 path=${localPath}, size=${stat.size}`);
+    } catch (error) {
+      const err: Error = error as Error;
+      Logger.warn(TAG, `缩略图缓存校验失败 path=${localPath}, error=${err.message}`);
+    }
+    return false;
+  }
+
+  private isImageMagicValid(localPath: string): boolean {
+    let file: fileIo.File | undefined = undefined;
+    try {
+      file = fileIo.openSync(localPath, fileIo.OpenMode.READ_ONLY);
+      const headerBuffer: ArrayBuffer = new ArrayBuffer(12);
+      const readLen: number = fileIo.readSync(file.fd, headerBuffer);
+      if (readLen < 4) {
+        return false;
+      }
+      const bytes: Uint8Array = new Uint8Array(headerBuffer);
+      const isJpg: boolean = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
+      const isPng: boolean = readLen >= 8 &&
+        bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47 &&
+        bytes[4] === 0x0D && bytes[5] === 0x0A && bytes[6] === 0x1A && bytes[7] === 0x0A;
+      const isGif: boolean = readLen >= 4 &&
+        bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38;
+      const isBmp: boolean = bytes[0] === 0x42 && bytes[1] === 0x4D;
+      const isWebp: boolean = readLen >= 12 &&
+        bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
+        bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
+      return isJpg || isPng || isGif || isBmp || isWebp;
+    } catch (error) {
+      const err: Error = error as Error;
+      Logger.warn(TAG, `缩略图文件头校验失败 path=${localPath}, error=${err.message}`);
+      return false;
+    } finally {
+      if (file !== undefined) {
+        try {
+          fileIo.closeSync(file);
+        } catch (error) {
+          const err: Error = error as Error;
+          Logger.warn(TAG, `关闭缩略图文件失败 path=${localPath}, error=${err.message}`);
+        }
+      }
+    }
+  }
+
+  private removeInvalidThumbCache(localPath: string): void {
+    if (StrUtil.isEmpty(localPath) || !FileUtil.accessSync(localPath)) {
+      return;
+    }
+    try {
+      fileIo.unlinkSync(localPath);
+      Logger.info(TAG, `已删除无效缩略图缓存 path=${localPath}`);
+    } catch (error) {
+      const err: Error = error as Error;
+      Logger.warn(TAG, `删除无效缩略图缓存失败 path=${localPath}, error=${err.message}`);
+    }
+  }
+
+  private resolveFileNameForTypeCheck(item: VideoItem): string {
+    const rawName: string = item.fileName ?? item.remote_rel_path ?? item.filePath;
+    if (StrUtil.isEmpty(rawName)) {
+      return '';
+    }
+    const hashIndex = rawName.indexOf('#');
+    const queryIndex = rawName.indexOf('?');
+    let endIndex = rawName.length;
+    if (queryIndex >= 0) {
+      endIndex = queryIndex;
+    }
+    if (hashIndex >= 0 && hashIndex < endIndex) {
+      endIndex = hashIndex;
+    }
+    return rawName.substring(0, endIndex);
+  }
+
+  private isVideoMediaItem(item: VideoItem): boolean {
+    if (item.mimeType && item.mimeType.toLowerCase().startsWith('video/')) {
+      return true;
+    }
+    const fileNameForCheck: string = this.resolveFileNameForTypeCheck(item);
+    return Utility.isVideoByExtension(fileNameForCheck);
+  }
+
+  private refreshThumbItem(item: VideoItem): void {
+    const dataIndex: number = this.dataSource.dataArray.indexOf(item);
+    if (dataIndex >= 0) {
+      this.dataSource.notifyDataChange(dataIndex);
+    }
+    this.listRefreshKey += 1;
+  }
+
+  private async prepareRemoteThumbSource(item: VideoItem): Promise<RemoteThumbSource> {
+    let playUrl: string = item.filePath;
+    if (StrUtil.isNotEmpty(playUrl)) {
+      const lowerPath = playUrl.toLowerCase();
+      const isDirectPath = lowerPath.startsWith('http://') || lowerPath.startsWith('https://') ||
+        lowerPath.startsWith('/') || lowerPath.startsWith('file://');
+      if (!isDirectPath) {
+        try {
+          playUrl = await setVideoUrlForSong(item, {
+            extractCover: false,
+            extractLyric: false,
+            extractAudioInfo: false
+          });
+        } catch (error) {
+          Logger.warn(TAG, `解析缩略图播放地址失败 name=${item.name}, error=${(error as Error).message}`);
+        }
+      }
+    }
+    const headers: Map<string, string> = await this.webdavManager.buildHttpHeadersWithAccountId(item, playUrl);
+    const source: RemoteThumbSource = {
+      playUrl: playUrl,
+      headers: headers
+    };
+    return source;
+  }
+
+  private async tryResolveRemoteMusicCover(item: VideoItem, token: number): Promise<void> {
+    const coverPath: string = await this.resolveRemoteThumbPath(item);
+    if (this.isThumbCacheValid(coverPath)) {
+      item.pixelMapPath = this.normalizeThumbImageSource(coverPath);
+      this.refreshThumbItem(item);
+      return;
+    }
+    this.removeInvalidThumbCache(coverPath);
+    const source: RemoteThumbSource = await this.prepareRemoteThumbSource(item);
+    if (StrUtil.isEmpty(source.playUrl)) {
+      return;
+    }
+    const headersArg: string = this.buildFfmpegHeadersArg(source.headers);
+    const rawCommands: string[] = this.buildRemoteAudioCoverFfmpegCmd(source.playUrl, headersArg, coverPath);
+    const commands: string[] = this.sanitizeFfmpegCommands(rawCommands);
+    if (commands.length < 2) {
+      return;
+    }
+    await this.runSerializedThumbnailFfmpeg(async (): Promise<void> => {
+      if (token !== this.thumbnailTaskToken) {
+        return;
+      }
+      await FFmpeg.execute(commands);
+    });
+    if (token !== this.thumbnailTaskToken) {
+      return;
+    }
+    if (this.isThumbCacheValid(coverPath)) {
+      item.pixelMapPath = this.normalizeThumbImageSource(coverPath);
+      this.refreshThumbItem(item);
+    }
+  }
+
+  private async generateRemoteVideoThumb(item: VideoItem, token: number): Promise<void> {
+    if (token !== this.thumbnailTaskToken) {
+      return;
+    }
+    const identity: string = this.buildThumbnailIdentity(item);
+    if (this.thumbnailRunningKeys.has(identity)) {
+      return;
+    }
+    this.thumbnailRunningKeys.add(identity);
+    try {
+      const isVideoFile: boolean = this.isVideoMediaItem(item);
+      if (!isVideoFile) {
+        await this.tryResolveRemoteMusicCover(item, token);
+        return;
+      }
+      const thumbPath: string = await this.resolveRemoteThumbPath(item);
+      if (this.isThumbCacheValid(thumbPath)) {
+        item.pixelMapPath = this.normalizeThumbImageSource(thumbPath);
+        this.refreshThumbItem(item);
+        return;
+      }
+      this.removeInvalidThumbCache(thumbPath);
+      const source = await this.prepareRemoteThumbSource(item);
+      if (StrUtil.isEmpty(source.playUrl)) {
+        return;
+      }
+      const headersArg: string = this.buildFfmpegHeadersArg(source.headers);
+      const rawCommands: string[] = this.buildRemoteThumbFfmpegCmd(source.playUrl, headersArg, thumbPath);
+      const commands: string[] = this.sanitizeFfmpegCommands(rawCommands);
+      if (commands.length < 2) {
+        return;
+      }
+      await this.runSerializedThumbnailFfmpeg(async (): Promise<void> => {
+        if (token !== this.thumbnailTaskToken) {
+          return;
+        }
+        await FFmpeg.execute(commands);
+      });
+      if (token !== this.thumbnailTaskToken) {
+        return;
+      }
+      if (this.isThumbCacheValid(thumbPath)) {
+        item.pixelMapPath = this.normalizeThumbImageSource(thumbPath);
+        this.refreshThumbItem(item);
+      }
+    } catch (error) {
+      const err: Error = error as Error;
+      Logger.info(TAG, `远程缩略图/封面生成失败 name=${item.name}, error=${err.message}`);
+    } finally {
+      this.thumbnailRunningKeys.delete(identity);
+    }
+  }
+
+  private scheduleRemoteThumbPrefetch(): void {
+    const token: number = this.thumbnailTaskToken + 1;
+    this.thumbnailTaskToken = token;
+    this.thumbnailRunningKeys.clear();
+    const mediaItems: VideoItem[] = this.songs.slice();
+    if (mediaItems.length <= 0) {
+      return;
+    }
+    const limitCount: number = Math.min(mediaItems.length, REMOTE_THUMB_MAX_TASK_COUNT);
+    const targetItems: VideoItem[] = mediaItems.slice(0, limitCount);
+    setTimeout((): void => {
+      void this.runRemoteThumbPrefetchQueue(targetItems, token);
+    }, 120);
+  }
+
+  private async runRemoteThumbPrefetchQueue(items: VideoItem[], token: number): Promise<void> {
+    for (let index: number = 0; index < items.length; index += 1) {
+      if (token !== this.thumbnailTaskToken) {
+        return;
+      }
+      const item: VideoItem = items[index];
+      if (StrUtil.isNotEmpty(item.pixelMapPath)) {
+        const currentThumbRef: string = item.pixelMapPath ? item.pixelMapPath : '';
+        if (currentThumbRef.startsWith('http')) {
+          continue;
+        }
+        const currentThumbPath: string = this.normalizeThumbLocalPath(currentThumbRef);
+        if (StrUtil.isNotEmpty(currentThumbPath) && this.isThumbCacheValid(currentThumbPath)) {
+          continue;
+        }
+      }
+      await this.generateRemoteVideoThumb(item, token);
+    }
+  }
+
   // 更新可见文件夹列表
   private updateVisibleFolders(): void {
     try {
@@ -398,6 +812,8 @@ export struct WebDavMainPage {
 
   aboutToDisappear(): void {
     // 取消订阅
+    this.thumbnailTaskToken += 1;
+    this.thumbnailRunningKeys.clear();
     this.webdavManager.unsubscribe(this.eventHandler);
     emitter.off(EventConstants.EVENT_WEBDAV_METADATA_UPDATED);
   }
@@ -416,6 +832,7 @@ export struct WebDavMainPage {
         this.updateVisibleFolders();
         this.breadcrumbs = this.webdavManager.getBreadcrumbs();
         this.syncSelectionAfterRefresh();
+        this.scheduleRemoteThumbPrefetch();
 
         // promptAction.showToast({
         //   message: '加载成功: ' + this.webDavFiles.filter(f => f.isDirectory).length + '个文件夹, ' + this.songs.length + '首歌曲'