AVMetadataExtractor 读本地 mp3 元数据为空?从 ID3 到 fd 生命周期的完整排查

前言

在 HarmonyOS 音频类应用里,读取本地 mp3 元信息是播放器、歌单、锁屏歌词的基石。社区高频问题是:用 media.createAVMetadataExtractor() 读 rawfile 里的 mp3,duration 有值,但 title / artist / album 全空;换资源管理器里某个 mp3 又正常。本文从 ID3 tag、版本兼容、资源定位、fd 生命周期四个角度排查,并给出"ID3 → 系统媒体库 → 文件名兜底 → 未知默认值"四级兜底的 ArkTS 封装。

问题描述

把 mp3 放进 src/main/resources/rawfile/,用 createAVMetadataExtractor + getRawFd 构造 AVFileDescriptor,调用 fetchMetadata:duration 拿到字符串(毫秒),title / artist / album 却是空串。换成电脑"属性 → 详细信息"能看到歌手的文件,代码未改却正常。说明问题不在调用方式,而在三处:元数据来源(ID3 tag)、文件是否真有 tag、fd 在读取时是否还活着。

细节解析

现象 → 根因 → 判别方法

现象根因判别方法
仅 duration 有值,title/artist/album 全空文件无 ID3 tag。duration 来自容器解析,其余字段来自 ID3v2/ID3v1 tagffprobe -show_format xxx.mp3 看有无 TAG: 段;或 Windows 属性 → 详细信息
部分文件正常、部分空ID3 版本兼容:ID3v2.4 的 UTF-8 帧或带 extended header 解析不全ffprobe 看 encoder;用 kid3 / MP3tag 降级为 ID3v2.3 再读
偶发全字段空fd 生命周期错误:fetchMetadata 尚未返回就 closeRawFd / release检查 closeRawFd 与 release 是否在 await fetchMetadata() 之后
自己拼绝对路径读不到rawfile 必须用 resourceManager.getRawFd()改用 getRawFd + AVFileDescriptor
后续逻辑数值异常duration 是 string(毫秒)非 numbertypeof duration === 'string',用 Number() 转换

fd 生命周期时序(关键)

fetchMetadata() 异步,真正读 fd 发生在 await 返回前的内部调度里,fd 全程须保持打开:

t0  getRawFd('music.mp3')            → 打开 hap 内 rawfile 的 fd,offset/length 指向资源字节区间
t1  AVFileDescriptor{ fd, offset, length } → extractor.fdSrc = avFileDescriptor
t2  await extractor.fetchMetadata()  → 解析容器并读 ID3 tag,对 fd 的读操作在此发生
t3  await extractor.release()        → 释放 extractor 内部解析资源
t4  resourceManager.closeRawFd('music.mp3') → 关闭 fd

错误做法是在 t2 之前(或 await 未返回时)执行 t4:fetch 时 fd 已关,读不到字节,title/artist/album 全部落空。getRawFd 与 closeRawFd 须配对,且以同一 rawfile 文件名为句柄。补充:AVMetadata.duration 是 string(毫秒),如 "215000",直接当 number 运算得 NaN,务必先 Number()。

示例代码

完整可编译 ArkTS 封装:rawfile 用 getRawFd 拿 fd,构造 AVFileDescriptor,await fetchMetadata 后再 release 与 closeRawFd,并叠加媒体库、文件名兜底。系统媒体库(userFileManager)查询需在 module.json5 声明 ohos.permission.READ_MEDIA,且 rawfile 内文件不自动编入媒体库,该级仅对已进入媒体库的音频有效。

import { media } from '@kit.MediaKit';
import { resourceManager } from '@kit.LocalizationKit';
import { userFileManager } from '@kit.MediaLibraryKit';
import { dataSharePredicates } from '@kit.ArkData';
import { BusinessError } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';

interface TrackMetadata {
  title: string;
  artist: string;
  album: string;
  durationMs: number;
}

const DEFAULT_ARTIST: string = '未知艺术家';
const DEFAULT_ALBUM: string = '未知专辑';
const DEFAULT_TITLE: string = '未知曲目';

// 第一级:用 AVMetadataExtractor 读 ID3,fd 必须在 fetchMetadata 之后才关闭
async function readFromExtractor(context: common.UIAbilityContext, rawFileName: string): Promise<TrackMetadata | null> {
  let extractor: media.AVMetadataExtractor = await media.createAVMetadataExtractor();
  let rawFd: resourceManager.RawFileDescriptor = context.resourceManager.getRawFd(rawFileName);
  let fdSrc: media.AVFileDescriptor = {
    fd: rawFd.fd,
    offset: rawFd.offset,
    length: rawFd.length
  };
  extractor.fdSrc = fdSrc;
  let avMetadata: media.AVMetadata = await extractor.fetchMetadata();

  let title: string = avMetadata.title ?? '';
  let artist: string = avMetadata.artist ?? '';
  let album: string = avMetadata.album ?? '';
  let duration: string = avMetadata.duration ?? '0';

  let result: TrackMetadata | null = null;
  if (title.length > 0 && artist.length > 0) {
    result = {
      title: title,
      artist: artist,
      album: album.length > 0 ? album : DEFAULT_ALBUM,
      durationMs: Number(duration)
    };
  }

  await extractor.release();
  context.resourceManager.closeRawFd(rawFileName);
  return result;
}

// 第二级:查系统媒体库(仅对已进入媒体库的音频有效,需 READ_MEDIA 权限)
async function readFromMediaLibrary(displayName: string): Promise<TrackMetadata | null> {
  let predicates: dataSharePredicates.DataSharePredicates = new dataSharePredicates.DataSharePredicates();
  predicates.equalTo(userFileManager.AudioItemColumn.DISPLAY_NAME, displayName);
  let fetchOptions: userFileManager.FetchOptions = {
    fetchColumns: [
      userFileManager.AudioItemColumn.TITLE,
      userFileManager.AudioItemColumn.ARTIST,
      userFileManager.AudioItemColumn.ALBUM_NAME,
      userFileManager.AudioItemColumn.DURATION
    ],
    predicates: predicates
  };

  let result: TrackMetadata | null = null;
  try {
    let fetchResult: userFileManager.AudioFetchResult = await userFileManager.getAudioAssets(fetchOptions);
    let count: number = fetchResult.getCount();
    if (count > 0) {
      let first: userFileManager.AudioItem = await fetchResult.getFirstObject();
      let title: string = first.title;
      let artist: string = first.artist;
      let album: string = first.albumName;
      let duration: number = first.duration;
      if (title.length > 0 && artist.length > 0) {
        result = {
          title: title,
          artist: artist,
          album: album.length > 0 ? album : DEFAULT_ALBUM,
          durationMs: duration
        };
      }
    }
  } catch (err) {
    let e: BusinessError = err as BusinessError;
    console.error(`readFromMediaLibrary failed: code=${e.code}, message=${e.message}`);
  }
  return result;
}

// 第三级 + 第四级:文件名兜底(artist - title.mp3),仍失败则用未知默认值
function readFromFileName(filePath: string): TrackMetadata {
  let lastSlash: number = filePath.lastIndexOf('/');
  let nameWithExt: string = lastSlash >= 0 ? filePath.substring(lastSlash + 1) : filePath;
  let dotIndex: number = nameWithExt.lastIndexOf('.');
  let nameOnly: string = dotIndex >= 0 ? nameWithExt.substring(0, dotIndex) : nameWithExt;

  let sepIndex: number = nameOnly.indexOf('-');
  let title: string = nameOnly;
  let artist: string = DEFAULT_ARTIST;
  if (sepIndex > 0) {
    artist = nameOnly.substring(0, sepIndex).trim();
    title = nameOnly.substring(sepIndex + 1).trim();
  }

  return {
    title: title.length > 0 ? title : DEFAULT_TITLE,
    artist: artist,
    album: DEFAULT_ALBUM,
    durationMs: 0
  };
}

// 四级兜底入口:ID3 → 系统媒体库 → 文件名 → 未知默认值
async function readTrackMetadata(context: common.UIAbilityContext, rawFileName: string, filePath: string): Promise<TrackMetadata> {
  let fromExtractor: TrackMetadata | null = await readFromExtractor(context, rawFileName);
  if (fromExtractor !== null) {
    return fromExtractor;
  }

  let fromLibrary: TrackMetadata | null = await readFromMediaLibrary(rawFileName);
  if (fromLibrary !== null) {
    return fromLibrary;
  }

  return readFromFileName(filePath);
}

总结

读本地 mp3 元数据为空,多数不是 API 用错,而是文件没有 ID3 tag——duration 来自容器、title/artist/album 来自 tag,数据源不同才会"一个有一个没有"。排错顺序:先用 ffprobe -show_format 确认 tag 是否存在、ID3v2.4 是否需降级;再确认 rawfile 用 getRawFd 定位;最关键的是保证 fd 在 await fetchMetadata() 返回后才 closeRawFd / release;最后记得 duration 是 string 毫秒。线上用四级兜底封装,无 tag 文件也能优雅降级到文件名与默认值,避免歌单空白。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐