Просмотр исходного кода

批量编辑标签的代码初步实现

onecold 1 год назад
Родитель
Сommit
9dcba190f7
2 измененных файлов с 597 добавлено и 11 удалено
  1. 115 11
      entry/src/main/ets/common/util/MusicTagUtils.ets
  2. 482 0
      entry/src/main/ets/view/TagsContentCover.ets

+ 115 - 11
entry/src/main/ets/common/util/MusicTagUtils.ets

@@ -18,8 +18,12 @@ import { FFMpegTags } from "./Utility";
 import { http } from "@kit.NetworkKit";
 import { http } from "@kit.NetworkKit";
 import { BusinessError } from "@kit.BasicServicesKit";
 import { BusinessError } from "@kit.BasicServicesKit";
 import ResponseCode from '@ohos.net.http';
 import ResponseCode from '@ohos.net.http';
-import { LogUtil, PreferencesUtil, StrUtil } from "@pura/harmony-utils";
+import { FileUtil, LogUtil, PreferencesUtil, StrUtil } from "@pura/harmony-utils";
 import { CommonConstants } from "../constants/CommonConstants";
 import { CommonConstants } from "../constants/CommonConstants";
+import { VideoItem } from "../../viewmodel/VideoItem";
+import MediaTable from "./MediaTable";
+import NetAxiosUtil from "./NetAxiosUtil";
+import { fileUri } from "@kit.CoreFileKit";
 
 
 /**
 /**
  * 修复音频文件的元数据标签
  * 修复音频文件的元数据标签
@@ -225,16 +229,17 @@ export async function changeMusicCover(
 
 
   // 如果是网络图片,先下载到临时文件
   // 如果是网络图片,先下载到临时文件
   let tempCoverPath = coverImagePath;
   let tempCoverPath = coverImagePath;
-  console.log(`onecold 开始下载 coverImagePath = [${coverImagePath}]`);
+  console.log(`onecold changeMusicCover coverImagePath = [${coverImagePath}]`);
   if (coverImagePath.startsWith('http')) {
   if (coverImagePath.startsWith('http')) {
     try {
     try {
       // 创建临时文件路径
       // 创建临时文件路径
       const tempDir = context.filesDir + '/'; // 默认缓存目录
       const tempDir = context.filesDir + '/'; // 默认缓存目录
-      const tempFileName = 'temp_cover.jpg';
+      const tempFileName = FileUtil.getFileName(inputPath)+'temp_cover.jpg';
       tempCoverPath = `${tempDir}${tempFileName}`;
       tempCoverPath = `${tempDir}${tempFileName}`;
 
 
       // 下载图片
       // 下载图片
       const result = await loadImageWithUrl(coverImagePath, tempCoverPath);
       const result = await loadImageWithUrl(coverImagePath, tempCoverPath);
+      console.log(`onecold changeMusicCover下载的图片 tempCoverPath = [${tempCoverPath}]`);
       if (!result) {
       if (!result) {
         console.error('onecold 下载封面图片失败');
         console.error('onecold 下载封面图片失败');
         // 清理可能已创建的临时文件
         // 清理可能已创建的临时文件
@@ -260,7 +265,7 @@ export async function changeMusicCover(
       return false;
       return false;
     }
     }
   }
   }
-  console.log(`onecold 开始下载 tempCoverPath = [${tempCoverPath}]`);
+  console.log(`onecold 获取新的图片地址 tempCoverPath = [${tempCoverPath}]`);
   // 构建FFmpeg命令
   // 构建FFmpeg命令
   const commands: string[] = [
   const commands: string[] = [
     "ffmpeg",
     "ffmpeg",
@@ -301,17 +306,27 @@ export async function changeMusicCover(
 
 
     // 验证文件是否存在
     // 验证文件是否存在
     try {
     try {
+
       const file = await fs.open(finalOutputPath, fs.OpenMode.READ_ONLY);
       const file = await fs.open(finalOutputPath, fs.OpenMode.READ_ONLY);
       await fs.close(file.fd);
       await fs.close(file.fd);
+      const table: MediaTable = new MediaTable(context);
+      console.log("onecold musicTags 开始图片入库中2");
+      //图片入库
+      await new Promise<void>((resolve, reject) => {
+        table.getRdbStore(context,  (err:Error) => {
+          err ? reject(err) : resolve();
+        });
+      });
+      table.updatePixelMapPath(inputPath,  fileUri.getUriFromPath(tempCoverPath), (success: boolean, error?: string) => {
+        if (success) {
 
 
-      // 如果是下载的临时图片,清理临时文件
-      if (tempCoverPath !== coverImagePath) {
-        try {
-          fs.unlinkSync(tempCoverPath);
-        } catch (unlinkError) {
-          console.warn('onecold 清理临时文件失败:', unlinkError);
+          console.log("onecold musicTags 更新音乐封面成功,数据库已同步");
+        } else {
+          console.error("onecold  musicTags 更新音乐封面数据库失败原因: " + error);
         }
         }
-      }
+        // Note: We don't resolve/reject here because we already returned res
+      });
+
 
 
       return true;
       return true;
     } catch (e) {
     } catch (e) {
@@ -457,4 +472,93 @@ interface lyricInfo{
   lyrics:string
   lyrics:string
   cover_url:string
   cover_url:string
   status:string
   status:string
+}
+
+/**
+ *api搜索封面
+ *item,根据title和artist
+ */
+export function searchCover(context:Context,item: VideoItem, title: string, artist: string): Promise<string> {
+  const table: MediaTable = new MediaTable(context);
+  return NetAxiosUtil.getLyricCover(title,  artist, PreferencesUtil.getStringSync('COVER_API',  '')).then(async (res) => {
+    LogUtil.debug("onecold  res =" + res);
+
+    // if (StrUtil.isNotEmpty(res)  && res !== 'unknown' && res !== 'Timeout was reached') {
+    //
+    //
+    //   // Update pixel map path but always return res regardless of success
+    //   table.updatePixelMapPath(item.filePath,  res, (success: boolean, error?: string) => {
+    //     if (success) {
+    //
+    //       console.log("onecold  更新音乐封面成功,数据库已同步");
+    //     } else {
+    //       console.error("onecold  更新音乐封面数据库失败原因: " + error);
+    //     }
+    //     // Note: We don't resolve/reject here because we already returned res
+    //   });
+    //
+    // }
+
+    return res; // Always return res regardless of updatePixelMapPath result
+  });
+}
+
+/**
+ * 查找同名的图片文件(png或jpg)
+ * @param filePath 音频文件路径
+ * @returns 图片文件路径,如果未找到则返回空字符串
+ */
+export function findLocalCoverImage(filePath: string): string {
+  try {
+    // 获取文件名(不包含扩展名)
+    const lastDotIndex = filePath.lastIndexOf('.');
+    const basePath = lastDotIndex >= 0 ? filePath.substring(0, lastDotIndex) : filePath;
+
+    // 检查可能的图片文件扩展名
+    const imageExtensions = ['.png', '.jpg', '.jpeg'];
+
+    for (const ext of imageExtensions) {
+      const imagePath = basePath + ext;
+      if (fs.accessSync(imagePath)) {
+        return imagePath;
+      }
+    }
+
+    return ''; // 未找到同名图片文件
+  } catch (error) {
+    console.warn(`查找同名图片文件失败: ${JSON.stringify(error)}`);
+    return '';
+  }
+}
+
+/**
+ * 同步歌词到数据库
+ * @param filePath 音频文件路径
+ * @returns 图片文件路径,如果未找到则返回空字符串
+ */
+export async function syncLyricToDB(context:Context,filePath: string,lyric:string) {
+  try {
+    const table: MediaTable = new MediaTable(context);
+    //图片入库
+    await new Promise<void>((resolve, reject) => {
+      table.getRdbStore(context,  (err:Error) => {
+        err ? reject(err) : resolve();
+      });
+    });
+    table.updateMediaInfo(filePath, '', '', '',lyric,
+      '','','','','', '','','',
+      (success: boolean, error?: string) => {
+
+        if (success) {
+          console.info(" onecold  同步歌词成功: " );
+        } else {
+          // ToastUtil.showToast('保存失败' + error?.toString())
+          console.error(" onecold  编辑信息数据库失败原因: " + error);
+        }
+
+
+      });
+  } catch (error) {
+    console.warn(`查找同名图片文件失败: ${JSON.stringify(error)}`);
+  }
 }
 }

+ 482 - 0
entry/src/main/ets/view/TagsContentCover.ets

@@ -0,0 +1,482 @@
+import { common, ConfigurationConstant } from '@kit.AbilityKit';
+import { CommonConstants } from '../common/constants/CommonConstants';
+import { VideoItem } from '../viewmodel/VideoItem';
+import { LazyDataSource } from '../common/util/LazyDataSource';
+import { LogUtil, PreferencesUtil, StrUtil, ToastUtil } from '@pura/harmony-utils';
+import fs from '@ohos.file.fs';
+import { changeMusicCover, findLocalCoverImage, getApiLyric, repairAudioMetadata,
+  searchCover,
+  syncLyricToDB} from '../common/util/MusicTagUtils';
+import { util } from '@kit.ArkTS';
+import { FFMpegTags } from '../common/util/Utility';
+
+//批量编辑标签
+// 批量编辑标签
+@Component
+export struct TagsContentCover {
+  private listScroller: ListScroller = new ListScroller()
+  onTagsResult = (_result: boolean) => {
+  }
+  @State isLyric:boolean = true
+  @State isCover:boolean = true
+  @Link isShowDrawer: boolean;
+  @State dataSource: LazyDataSource<VideoItem> = new LazyDataSource([])
+  @Prop selectedFiles: Array<VideoItem>
+  @StorageProp('topRectHeight') topRectHeight: number = 0;
+  @State isDarkMode: boolean = false
+  @StorageProp('currentColorMode') @Watch('onColorModeChange') currentMode: number =
+    ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT;
+  @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR;
+  context =  this.getUIContext().getHostContext() as common.UIAbilityContext
+  // 新增状态用于进度显示
+  @State currentProgress: number = 0;
+  @State totalFiles: number = 0;
+  @State currentFileIndex: number = 0;
+  @State currentFileName: string = '';
+  @State isEmbedding: boolean = false;
+  @State embedSuccessCount: number = 0;
+  @State embedFailedCount: number = 0;
+
+  // 新增状态用于跟踪每个文件的嵌入状态
+  @State embedStatusMap: Map<string, boolean> = new Map<string, boolean>();
+
+
+  onColorModeChange() {
+    this.isDarkMode = this.currentMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK
+  }
+
+  async aboutToAppear() {
+    // 确保 selectedFiles 有默认值后再初始化 dataSource
+    if (this.selectedFiles && this.selectedFiles.length > 0) {
+      this.dataSource = new LazyDataSource(this.selectedFiles)
+    } else {
+      this.dataSource = new LazyDataSource([])
+    }
+  }
+
+
+
+  build() {
+    Column(){
+      this.topTitleBar()
+      this.TagSetting()
+      this.startButton()
+      this.listView()
+
+    }
+    .height('100%')
+    .width('100%')
+    .backgroundColor($r('app.color.start_window_background'))
+  }
+
+  @Builder
+  listView() {
+    Column(){
+      List({ scroller: this.listScroller }) {
+        ListItemGroup({ header: this.listHeader() }) {
+        LazyForEach(this.dataSource, (item: VideoItem, index: number) => {
+          ListItem() {
+            Column() {
+              this.MusicItem(item, index)
+            }
+          }
+          .transition(TransitionEffect.asymmetric(
+            TransitionEffect.scale({ x: 1.2, y: 1.2 }).animation({ duration: 500 }),
+            TransitionEffect.scale({ x: 0, y: 0 })
+          ))
+          .clickEffect({ level: ClickEffectLevel.LIGHT })
+        }, (item: VideoItem) => item.filePath)
+        }
+
+      }
+      .cachedCount(2)
+      // .layoutWeight(1)
+      // .height('100%')
+      .transition(TransitionEffect.asymmetric(
+        TransitionEffect.scale({ x: 1.2, y: 1.2 }).animation({ duration: 500 }),
+        TransitionEffect.scale({ x: 0, y: 0 })
+      ))
+
+    }
+    .borderRadius(20)
+    .padding({top:10,bottom:6})
+    .height('auto')
+    .backgroundColor($r('app.color.index_background'))
+    .margin({top:20,right:20,left:20})
+    .layoutWeight(1)
+    .height('100%')
+  }
+
+  @Builder
+  listHeader() {
+    Stack({alignContent:Alignment.Start}){
+      Text(`共${this.selectedFiles.length}首歌`)
+        .fontSize(14)
+        .maxLines(1)
+        .textAlign(TextAlign.Start)
+        .padding({ left: 25 })
+        .fontColor($r('app.color.text_color'))
+        .margin({bottom:6,top:4,left: 6 })
+    }
+    .height('auto').width('100%')
+  }
+
+
+  @Builder
+  TagSetting() {
+    Column(){
+
+      Row() {
+        SymbolGlyph($r('sys.symbol.doc_text'))
+          .fontSize(20)
+          .fontColor([this.themeColor])
+          .alignSelf(ItemAlign.Center)
+          .margin({ left: 15 })
+        Text('内嵌歌词')
+          .margin({ left: 8 })
+          .fontSize(15)
+          .fontColor($r('app.color.text_color'))
+          .fontWeight(480)
+          .layoutWeight(1)
+        Toggle({ type: ToggleType.Switch, isOn: this.isLyric })
+          .selectedColor(this.themeColor)
+          .switchPointColor(Color.White)
+          .margin({ right: 18 })
+          .onChange((checked: boolean) => {
+            this.isLyric = checked;
+          })
+          .width(50)
+          .height(30);
+      }
+      .height(55)
+      .clickEffect({level:ClickEffectLevel.HEAVY,scale:0.6})
+
+      Row() {
+        SymbolGlyph($r('sys.symbol.picture'))
+          .fontSize(20)
+          .fontColor([this.themeColor])
+          .alignSelf(ItemAlign.Center)
+          .margin({ left: 15 })
+        Text('内嵌封面')
+          .margin({ left: 8 })
+          .fontSize(15)
+          .fontColor($r('app.color.text_color'))
+          .fontWeight(480)
+          .layoutWeight(1)
+        Toggle({ type: ToggleType.Switch, isOn: this.isCover })
+          .selectedColor(this.themeColor)
+          .switchPointColor(Color.White)
+          .margin({ right: 18 })
+          .onChange((checked: boolean) => {
+            this.isCover = checked;
+          })
+          .width(50)
+          .height(30);
+      }
+      .height(55)
+      .clickEffect({level:ClickEffectLevel.HEAVY,scale:0.6})
+
+    }
+    .borderRadius(20)
+    .backgroundColor($r('app.color.index_background'))
+    .margin({top:20,right:20,left:20})
+  }
+
+  @Builder
+  startButton(){
+    Stack(){
+      Progress({ value: this.currentProgress, total: this.totalFiles,
+        type: ProgressType.Capsule }).height(55)
+        .margin({top:20,right:20,left:20})
+        .backgroundColor($r('app.color.index_background'))
+      Button({ type: ButtonType.Capsule, stateEffect: true }) {
+        Row() {
+          // 菜单图标
+          SymbolGlyph($r('sys.symbol.star_trophy'))// .size({ width: 22, height: 22 })
+            .fontSize(22)
+            .fontColor([this.themeColor])
+            .alignSelf(ItemAlign.Center)
+            .margin({ left: 25 })
+
+          // 菜单标题
+          Text('开始内嵌')
+            .margin({ left: 10, right: 20 })
+            .fontSize(15)
+            .fontWeight(480)
+          Blank()
+          // 右侧箭头
+          Image($r('app.media.arrow_right'))
+            .width(22)
+            .height(22)
+            .margin({ left: 0, right: 20 })
+            .align(Alignment.Center)
+        }
+        .width('100%')
+      }
+      .margin({top:20,right:20,left:20})
+      .height(55)
+      .enabled(!this.isEmbedding)
+      .backgroundColor(Color.Transparent)
+      .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
+      .onClick(()=>{
+        this.startEmbed()
+      })
+
+
+    }
+
+  }
+
+  /**
+   * 开始嵌入封面和歌词
+   */
+  async startEmbed() {
+    if (this.isEmbedding || !this.selectedFiles || this.selectedFiles.length === 0) {
+      return;
+    }
+
+    this.isEmbedding = true;
+    this.currentProgress = 0;
+    this.currentFileIndex = 0;
+    this.embedSuccessCount = 0;
+    this.embedFailedCount = 0;
+    this.totalFiles = this.selectedFiles.length;
+
+    // 清空之前的嵌入状态
+    this.embedStatusMap = new Map<string, boolean>();
+
+    // 创建异步任务数组
+    const embedTasks = this.selectedFiles.map(async (item: VideoItem, index: number) => {
+      try {
+        this.currentFileIndex = index + 1;
+        this.currentFileName = item.name;
+
+
+        let success = true;
+
+        // 1. 处理封面 (如果启用且需要处理)
+        if(this.isCover){
+          let  defaultCover = ''
+          // 先查找同名的png或jpg文件
+          if (StrUtil.isEmpty(item.pixelMapPath) && item.filePath) {
+            defaultCover = findLocalCoverImage(item.filePath);
+
+            // 如果没有找到同名图片文件,则进行在线查询
+            if (StrUtil.isEmpty(defaultCover)) {
+              const imagePath: string | undefined = await searchCover(
+                this.context,
+                item,
+                item.name,
+                item?.artist || ''
+              );
+              defaultCover = imagePath || '';
+            }
+          }else if(item.pixelMapPath&&item.pixelMapPath.startsWith('http')){
+            defaultCover = item.pixelMapPath
+          }
+          if (StrUtil.isNotEmpty(defaultCover)) {
+            // 确保 filePath 存在
+            if (item.filePath) {
+              const result = await changeMusicCover(
+                this.context,
+                item.filePath,
+                defaultCover,
+                true // 覆盖原文件
+              );
+              if (!result) {
+                success = false;
+              }
+            } else {
+              success = false;
+            }
+          }
+        }
+
+
+        // 2. 处理歌词 (如果启用)
+        if (this.isLyric) {
+          let lyricContent = item.lyricContent || ''; // 默认为空字符串
+
+          // 如果歌词为空,尝试读取同名.lrc文件
+          if (StrUtil.isEmpty(lyricContent) && item.filePath) {
+            const lrcPath = item.filePath.substring(0, item.filePath.lastIndexOf('.')) + '.lrc';
+            try {
+              if (fs.accessSync(lrcPath)) {
+                const file = fs.openSync(lrcPath, fs.OpenMode.READ_ONLY);
+                const buf = new ArrayBuffer(102400); // 100KB缓冲区
+                const len = fs.readSync(file.fd, buf);
+                const textDecoder = new util.TextDecoder('utf-8');
+                // 修复错误3: 使用 Uint8Array 包装 ArrayBuffer
+                lyricContent = textDecoder.decode(new Uint8Array(buf.slice(0, len)));
+                fs.closeSync(file);
+              }
+            } catch (error) {
+              console.warn(`读取LRC文件失败: ${JSON.stringify(error)}`);
+            }
+          }
+
+          // 如果仍然为空,尝试从API获取歌词
+          if (StrUtil.isEmpty(lyricContent) && item.filePath) {
+            try {
+              const apiUrl = PreferencesUtil.getStringSync('LRC_API', '');
+              // 修复错误1: 添加非空检查
+              if (StrUtil.isNotEmpty(apiUrl)) {
+                lyricContent = await getApiLyric(
+                  apiUrl,
+                  item.name || '', // 修复可能为undefined的情况
+                  item.artist || '', // 修复可能为undefined的情况
+                  apiUrl.includes(CommonConstants.LRC_API_2)
+                );
+              }
+            } catch (error) {
+              console.warn(`获取API歌词失败: ${JSON.stringify(error)}`);
+            }
+          }
+
+          // 如果有歌词内容,嵌入到音频文件
+          if (StrUtil.isNotEmpty(lyricContent) && item.filePath) {
+            const metadata: FFMpegTags = {};
+            // 修复错误2: 添加非空检查
+            const result = await repairAudioMetadata(
+              item.filePath,
+              lyricContent,
+              metadata,
+              true // 覆盖原文件
+            );
+            if (!result) {
+              success = false;
+            }else{
+              //内嵌成功后同步歌词到数据库
+              syncLyricToDB(this.context,item.filePath,lyricContent)
+            }
+          }
+        }
+
+        // 更新嵌入状态
+        if (item.filePath) {
+          this.embedStatusMap.set(item.filePath, success);
+        }
+
+        if (success) {
+          this.embedSuccessCount++;
+        } else {
+          this.embedFailedCount++;
+        }
+        // 滚动到当前处理的项目位置
+        // this.listScroller.scrollToIndex(index);
+        this.currentProgress = Math.floor(((index + 1) / this.totalFiles) * 100);
+        return success;
+      } catch (error) {
+        console.error(`处理文件 ${item.name} 失败: ${JSON.stringify(error)}`);
+        this.embedFailedCount++;
+        return false;
+      }
+    });
+
+    try {
+      // 并发执行所有嵌入任务
+      await Promise.all(embedTasks);
+    } catch (error) {
+      this.onTagsResult(false)
+      console.error(`批量嵌入过程出错: ${JSON.stringify(error)}`);
+    } finally {
+      ToastUtil.showToast(`嵌入完成: 成功 ${this.embedSuccessCount}, 失败 ${this.embedFailedCount}`)
+      this.isEmbedding = false;
+      this.currentProgress = 0;
+      this.totalFiles = this.selectedFiles.length;
+      this.onTagsResult(true)
+      // 可以在这里添加完成后的提示或回调
+      console.info(`嵌入完成: 成功 ${this.embedSuccessCount}, 失败 ${this.embedFailedCount}`);
+    }
+  }
+
+
+  @Builder
+  topTitleBar() {
+    // 顶部安全区和自定义标题栏
+    Column() {
+      // 顶部安全区
+      Blank()
+        .height(this.topRectHeight)
+        .backgroundColor(this.isDarkMode ? $r('app.color.title_bar_bg') : this.themeColor)
+        .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP])
+      // 自定义标题栏(Stack实现绝对居中)
+      Stack() {
+        // 居中标题
+        Text('批量内嵌标签')
+          .fontSize(18)
+          .fontColor(Color.White)
+          .align(Alignment.Center)
+        // 左右按钮
+        Row() {
+          Image($r('app.media.left_back_white'))
+            .width(26)
+            .height(26)
+            .margin({ left: 12, right: 8 })
+            .onClick(() => {
+              this.getUIContext().animateTo({ duration: 666 }, () => {
+                // 动画闭包内控制Image组件的出现和消失
+                this.isShowDrawer = !this.isShowDrawer
+              })
+            })
+          Blank().flexGrow(1)
+          Blank().width(32)
+        }
+        .height(48)
+        .width('100%')
+        .alignItems(VerticalAlign.Center)
+      }
+      .height(48)
+      .width('100%')
+      .backgroundColor(this.isDarkMode ? $r('app.color.title_bar_bg') : this.themeColor)
+    }
+  }
+
+  @Builder
+  private MusicItem(item: VideoItem, index?: number) {
+    Button({ type: ButtonType.Normal, stateEffect: true }) {
+        Column() {
+          Row() {
+            SymbolGlyph($r('sys.symbol.music'))
+              .fontSize(20)
+              .fontColor([this.themeColor])
+              .alignSelf(ItemAlign.Center)
+              .padding({ left: 25 })
+            Text(item.name)
+              .fontSize(14)
+              .maxLines(1)
+              .padding({ left: 5 })
+              .textOverflow({ overflow: TextOverflow.MARQUEE })
+              .animation({
+                duration: 555,
+                curve: 'Linear',
+              })
+              .fontColor(Color.Gray)
+              .margin({ left: 8 })
+            Blank()
+            // 根据嵌入状态显示勾选图标
+            SymbolGlyph($r('sys.symbol.checkmark_circle'))
+              .fontSize(20)
+              .fontColor([this.themeColor])
+              .alignSelf(ItemAlign.Center)
+              .padding({ right: 25 })
+              .animation({
+                duration: 666,
+                curve: 'ease-in-out' // 可选动画曲线
+              })
+              .visibility(this.embedStatusMap.get(item.filePath)?Visibility.Visible:Visibility.None)
+
+          }
+          .layoutWeight(1)
+          .height('100%')
+          .width('100%')
+
+        }
+    }
+    .backgroundColor(Color.Transparent)
+    .width('100%')
+    .height(40)
+  }
+}
+
+