背景

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

Music Lib

为了清晰管理,库建立之初我就采用了统一的文件命名格式:歌手 - 歌曲.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 Metadata

首先需要了解这些元数据的存储方式。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"))
}

总结一下我的实现思路:

  1. 定义三个目录:源文件夹、输出文件夹、修复文件夹。
  2. 遍历源文件夹下所有 MP3 文件(防止读取到比如 .DS_Store 等非 MP3 文件,当然你也可以使用 Media Type 校验文件类型)。
  3. 解析文件名得到歌手和歌曲名,在输出文件夹中创建歌手文件夹。
  4. 读取 MP3 的 ID3v2 标签,检查标签中的歌手和歌曲是否与文件名一致。
  5. 如果标签中的信息与文件名一致,且专辑名不为空,则将该文件移动到输出文件夹下对应的 歌手/专辑 子目录中。
  6. 如果标签中的信息与文件名不一致,尝试将其转换为 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"

输入密码后即可解除,后面跑脚本就没有问题了。