AVMetadataExtractor 读本地 mp3 元数据为空?从 ID3 到 fd 生命周期的完整排查
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 tag | ffprobe -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(毫秒)非 number | typeof 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 文件也能优雅降级到文件名与默认值,避免歌单空白。
更多推荐


所有评论(0)