背景
数年前为了应对各音乐平台的版权割据,我开始建立自己的离线音乐库。只是当年的我没有意识到,它会庞大到将近 6 千首歌——这还是在我近几年都没有维护的情况下。

为了清晰管理,库建立之初我就采用了统一的文件命名格式:歌手 - 歌曲.mp3。

终于有一天我面对着这个 20 几 GB 的文件夹手足无措时,我意识到,它还需要进一步整理。
我的想法是这样的,为了方便索引,我应该为歌手建立一个目录,然后歌手下再建立专辑子目录,最后把每首歌曲归类到对应的专辑中去。
Music
├── Singer 1
│ ├── Album 1
│ │ ├── Song 1
│ │ ├── Song 2
│ │ └── ···
│ ├── Album 2
│ │ ├── Song 1
│ │ ├── Song 2
│ │ └── ···
│ └── ···
├── Singer 2
│ ├── Album 1
│ │ ├── Song 1
│ │ ├── Song 2
│ │ └── ···
│ ├── Album 2
│ │ ├── Song 1
│ │ ├── Song 2
│ │ └── ···
│ └── ···
└── ···
歌手名字和歌曲名字都好说,得益于我规范的命名,直接解析文件名就可以拿到,那么专辑名应该如何获取呢?
点开 MP3 文件的属性,可以看到它内部就存储了标题、歌手、专辑等信息,这恰好能够满足我的需要。

首先需要了解这些元数据的存储方式。MP3 文件的元数据通常遵循 ID3 标准,主要分为 ID3v1 和 ID3v2 两种。
ID3v1:位于文件尾部,长度为 128 字节,如果 MP3 文件中有 APE 格式信息,则在 APE 信息之后。不支持封面和一些特殊字符,兼容性较好,但信息容量有限。
ID3v2:位于文件头部,可以包含封面和特殊字符等,长度任意,是目前最常用的标准。它由一个标签头和多个帧(Frame)组成。
ID3v2 当前最新版本是 ID3v2.4,但一些老设备或软件可能只支持 ID3v2.3。
ID3v2.4 对 ID3v2.3 做了一些改进:
- 容量和限制:ID3v2.4 相比 ID3v2.3 有更好的扩展性,可以支持更大容量的文件。
- 标签大小:ID3v2.4 新增了 16 位和 32 位的大小字段,可以表示更大的标签尺寸。
- 文本编码:ID3v2.4 强制使用 UTF-16 编码,而 ID3v2.3 则默认使用 ISO-8859-1 编码。
- 帧类型:ID3v2.4 废弃了一些过时的帧类型,并引入了新的帧类型,如:用户自定义文本信息等。
既然知道了元数据存储的位置,如何读取呢?直接解析文件的 ByteArray 是一种方案,但是这种方法过于硬核,使用封装过的三方库显然更加简单易用。
使用 mp3agic
我找到了一个名为 mp3agic 的库,尽管该仓库上一次提交已经是 2022 年,并且在 2024 年已标注为不再积极维护,但我实际测试下来,仍能够满足我的需求。
先来看看使用方法。
引入依赖:
dependencies {
implementation("com.mpatric:mp3agic:0.9.1")
}
读取 MP3 文件:
fun open(file: File) {
val mp3File = Mp3File(it)
val length = mp3File.lengthInSeconds
val bitrate = mp3File.bitrate
val sampleRate = mp3File.sampleRate
// ...
}
支持直接使用路径或者 File 实例构建 Mp3File。
移除 ID3 或者自定义 Tag:
fun deleteTag(mp3File: Mp3File, savePath: String) {
if (mp3File.hasId3v1Tag()) {
mp3File.removeId3v1Tag()
}
if (mp3File.hasId3v2Tag()) {
mp3File.removeId3v2Tag()
}
if (mp3File.hasCustomTag()) {
mp3File.removeCustomTag()
}
mp3File.save(savePath)
}
需要留意的是修改后调用 save() 保存时,不能直接覆盖原文件,需要提供一个新的保存路径,否则会抛出异常。
获取 ID3v1 的值:
fun getId3v1(mp3File: Mp3File) {
if (mp3File.hasId3v1Tag()) {
val v1Tag = mp3File.id3v1Tag
val track = v1Tag.track
val artist = v1Tag.artist
val album = v1Tag.album
val title = v1Tag.title
val year = v1Tag.year
val genre = v1Tag.genre
val genreDesc = v1Tag.genreDescription
val comment = v1Tag.comment
val version = v1Tag.version
// ...
}
}
设置 ID3v1 的值:
fun setId3v1(mp3File: Mp3File, savePath: String) {
val v1Tag = if (mp3File.hasId3v1Tag()) {
mp3File.id3v1Tag
} else {
ID3v1Tag().also { mp3File.id3v1Tag = it }
}
v1Tag.track = "5"
v1Tag.artist = "An Artist"
v1Tag.title = "The Title"
v1Tag.album = "The Album"
v1Tag.year = "2001"
v1Tag.genre = 12
v1Tag.comment = "Some comment"
// ...
mp3File.save(savePath)
}
其实就是简单的调用 Getter 和 Setter。
获取 ID3v2 的值:
fun getId3v2(mp3File: Mp3File) {
if (mp3File.hasId3v2Tag()) {
val v2Tag = mp3File.id3v2Tag
val track = v2Tag.track
val artist = v2Tag.artist
val album = v2Tag.album
val title = v2Tag.title
val year = v2Tag.year
val genre = v2Tag.genre
val genreDesc = v2Tag.genreDescription
val comment = v2Tag.comment
val version = v2Tag.version
val lyrics = v2Tag.lyrics
val composer = v2Tag.composer
val publisher = v2Tag.publisher
val origArtist = v2Tag.originalArtist
val albumArtist = v2Tag.albumArtist
val copyright = v2Tag.copyright
val url = v2Tag.url
val encoder = v2Tag.encoder
val albumImage = v2Tag.albumImage
if (albumImage != null) {
val mime = v2Tag.albumImageMimeType
// Write image to file - can determine appropriate file extension from the mime type
val file = RandomAccessFile("album-artwork", "rw")
file.write(albumImage);
file.close();
}
// ...
}
}
因为 ID3v2 支持设置专辑封面,所以也可以获取到封面图片。
设置 ID3v2 的值:
fun setId3v2(mp3File: Mp3File, savePath: String) {
val v2Tag = if (mp3File.hasId3v2Tag()) {
mp3File.id3v2Tag
} else {
ID3v24Tag().also { mp3File.id3v2Tag = it }
}
v2Tag.track = "5"
v2Tag.artist = "An Artist"
v2Tag.title = "The Title"
v2Tag.album = "The Album"
v2Tag.year = "2001"
v2Tag.genre = 12
v2Tag.comment = "Some comment"
v2Tag.lyrics = "Some lyrics"
v2Tag.composer = "The Composer"
v2Tag.publisher = "A Publisher"
v2Tag.originalArtist = "Another Artist"
v2Tag.albumArtist = "An Artist"
v2Tag.copyright = "Copyright"
v2Tag.url = "http://foobar"
v2Tag.encoder = "The Encoder"
v2Tag.setAlbumImage(byteArray, mime)
// ...
mp3File.save("MyMp3File.mp3")
}
Mp3File 支持创建 ID3v2.2、ID3v2.3、ID3v2.4,可以按需创建。
编写整理脚本
有了上面的工具,下面就可以写一个符合我预期的脚本了:
object MusicScript {
private const val SOURCE_DIR = "/Users/Liarr/mp3-source"
private const val RESULT_DIR = "/Users/Liarr/mp3-result"
private const val FIXED_DIR = "/Users/Liarr/mp3-fixed"
fun group() {
val dir = File(SOURCE_DIR)
dir.listFiles()?.forEach {
if (it.name.endsWith(".mp3")) { // 避免读到本地 .DS_Store 等非 MP3 文件
val regex = """(.*) - (.*)\.mp3""".toRegex()
val matchResult = regex.find(it.name)
val fileArtist = matchResult?.groupValues?.get(1)
val fileTitle = matchResult?.groupValues?.get(2)
if (fileArtist == null || fileTitle == null) {
return@forEach
}
val singerDir = File(RESULT_DIR, fileArtist)
if (singerDir.exists().not()) {
singerDir.mkdirs() // 歌手文件夹不存在,先创建
}
val mp3File = Mp3File(it)
if (mp3File.hasId3v2Tag()) {
val v2Tag = mp3File.id3v2Tag
// ID3v2 Tag 里面的数据和文件名解析出来的不一致,大概率是乱码了,尝试修正
if (fileArtist != v2Tag.artist || fileTitle != v2Tag.title) {
val tagArtist = v2Tag.artist.correctCharset()
val tagTitle = v2Tag.title.correctCharset()
val tagAlbum = v2Tag.album.correctCharset()
if (tagArtist == fileArtist && tagTitle == fileTitle) { // 转码正确,重设 ID3v2
v2Tag.artist = tagArtist
v2Tag.title = tagTitle
v2Tag.album = tagAlbum
// save 方法不允许覆盖原文件,先存到单独文件夹,后面再处理
mp3File.save(FIXED_DIR + File.separator + it.name)
return@forEach
} else { // 转码后也不匹配,尝试读取 ID3v1
if (mp3File.hasId3v1Tag().not()) {
return@forEach
}
val v1Tag = mp3File.id3v1Tag
if (fileArtist != v1Tag.artist || fileTitle != v1Tag.title) {
return@forEach
}
// ID3v1 里面的数据正确,重设 ID3v2
v2Tag.artist = v1Tag.artist
v2Tag.title = v1Tag.title
v2Tag.album = v1Tag.album
// save 方法不允许覆盖原文件,先存到单独文件夹,后面再处理
mp3File.save(FIXED_DIR + File.separator + it.name)
return@forEach
}
}
if (v2Tag.album.isBlank().not()) {
val albumDir = File(singerDir, v2Tag.album)
if (albumDir.exists().not()) {
albumDir.mkdirs()
}
val f = File(albumDir, it.name)
it.renameTo(f)
}
}
}
}
}
private fun String.correctCharset() = String(this.toByteArray(Charsets.ISO_8859_1), Charset.forName("gbk"))
}
总结一下我的实现思路:
- 定义三个目录:源文件夹、输出文件夹、修复文件夹。
- 遍历源文件夹下所有 MP3 文件(防止读取到比如
.DS_Store等非 MP3 文件,当然你也可以使用 Media Type 校验文件类型)。 - 解析文件名得到歌手和歌曲名,在输出文件夹中创建歌手文件夹。
- 读取 MP3 的 ID3v2 标签,检查标签中的歌手和歌曲是否与文件名一致。
- 如果标签中的信息与文件名一致,且专辑名不为空,则将该文件移动到输出文件夹下对应的
歌手/专辑子目录中。 - 如果标签中的信息与文件名不一致,尝试将其转换为 GBK 编码,若转换后匹配,则修正标签并保存到修复文件夹,仍不匹配则尝试读取 ID3v1 标签,如果 ID3v1 的信息与文件名一致,则将其写入到 ID3v2,并保存到修复文件夹中。
为什么要做文件名和 ID3v2 的匹配判断呢,因为跑脚本的过程中我发现,一些 MP3 文件解析出来的结果是乱码,系统播放器也不能正常识别:

这种情况下大概率就是上文中提到的 ID3v2.3 默认使用 ISO-8859-1 编码导致的,所以会出现跟文件名跟 ID3v2 不一致的问题,尝试将其转换为 GBK 编码。
执行脚本后,输出文件夹中就归类了本来就符合要求的 MP3 文件,修复文件夹中则得到了经过处理的 MP3 文件,此时可以手动检查修复文件夹中的文件,确认无误后替换源文件,再次运行脚本即可。这样既保证了自动化效率,又留有人工复核的空间。
二次执行脚本之后剩下的就是无法根据我们定义的规则处理的文件,那就需要手动处理了。
遇到的小插曲
第一次运行脚本时,发现许多文件无法移动,调试到 renameTo() 方法返回了 false,排查发现是权限问题,这些无法被移动的文件都被设置了 uchg 标志,估计是多年前为了防止意外修改而设置的只读属性。
执行命令:
➜ ls -lO "/Users/Liarr/mp3-source"
# -rwxr-xr-x 1 hugecore staff uchg 4199728 Jan 24 2017 陈奕迅 - 天下无双.mp3
# ...
可以看到该目录下输出的文件信息中都包含 uchg 标志(即 user immutable),有这个标志的文件无法通过脚本进行修改、删除、重命名、移动等操作,所以需要先执行命令解除 uchg 标志:
➜ sudo chflags -R nouchg "/Users/Liarr/mp3-source"
输入密码后即可解除,后面跑脚本就没有问题了。
