使用 Groovy 脚本清理音乐标签

我演示了一个 Groovy 脚本,用于清理混杂的标签字段。
2 位读者喜欢这篇文章。
Woman programming

WOCinTech Chat。由 Opensource.com 修改。CC BY-SA 4.0

最近,我一直在研究 Groovy 如何简化 Java。在本系列中,我将开发几个脚本来帮助清理我的音乐收藏。在我的上一篇文章中,我使用了之前开发的框架来创建唯一文件名列表以及这些文件名在音乐收藏目录中出现的次数计数。然后,我使用 Linux find 命令来删除我不想要的文件。

在本文中,我演示了一个 Groovy 脚本,用于清理混杂的标签字段。

警告:此脚本会更改音乐标签,因此至关重要的是,您要备份您用于测试代码的音乐收藏。

回到问题

如果您还没有阅读本系列之前的文章,请先阅读它们再继续,以便您了解音乐目录的预期结构、我创建的框架以及如何检测和使用 FLAC、MP3 和 OGG 文件。

Vorbis 和 ID3 标签

我没有很多 MP3 音乐文件。通常,我更喜欢使用 FLAC。但有时只有 MP3 版本可用,或者免费 MP3 下载随黑胶唱片购买而来。因此,在这个脚本中,我必须能够同时处理两者。随着我越来越熟悉 JAudiotagger,我了解到 ID3 标签(MP3 使用)的外观,并且我发现我在本系列的第 2 部分中发现的一些“不需要的”字段标签 ID 实际上非常有用。

现在是时候使用这个框架来获取音乐收藏中所有标签字段 ID 的列表及其计数,开始确定哪些属于,哪些不属于

1        @Grab('net.jthink:jaudiotagger:3.0.1')
2        import org.jaudiotagger.audio.*
3        import org.jaudiotagger.tag.*
4        def logger = java.util.logging.Logger.getLogger('org.jaudiotagger');
5        logger.setLevel(java.util.logging.Level.OFF);
6        // Define the music library directory
7        def musicLibraryDirName = '/var/lib/mpd/music'
8        // Define the tag field id accumulation map
9        def tagFieldIdCounts = [:]
10        // Print the CSV file header
11        println "tagFieldId|count"
12        // Iterate over each directory in the music libary directory
13        // These are assumed to be artist directories
14        new File(musicLibraryDirName).eachDir { artistDir ->
15            // Iterate over each directory in the artist directory
16            // These are assumed to be album directories
17            artistDir.eachDir { albumDir ->
18                // Iterate over each file in the album directory
19                // These are assumed to be content or related
20                // (cover.jpg, PDFs with liner notes etc)
21                albumDir.eachFile { contentFile ->
22                    // Analyze the file and print the analysis
23                    if (contentFile.name ==~ /.*\.(flac|mp3|ogg)/) {
24                        def af = AudioFileIO.read(contentFile)
25                        af.tag.fields.each { tagField ->
26                            tagFieldIdCounts[tagField.id] = tagFieldIdCounts.containsKey(tagField.id) ? tagFieldIdCounts[tagField.id] + 1 : 1
27                        }
28                    }
29                }
30            }
31        }
32        tagFieldIdCounts.each { key, value ->
33            println "$key|$value"
34        }

第 1-7 行最初出现在本系列的第 2 部分中。

第 8-9 行定义了一个用于累积标签字段 ID 和出现次数计数的映射。

第 10-21 行也出现在之前的文章中。它们深入到单个内容文件的级别。

第 23-28 行确保正在使用的文件是 FLAC、MP3 或 OGG。第 23 行使用 Groovy 匹配运算符 ==~ 和斜线正则表达式来过滤掉所需的文件。

第 24 行使用 org.jaudiotagger.audio.AudioFileIO.read() 从内容文件中获取标签主体。

第 25-27 行使用 org.jaudiotagger.tag.Tag.getFields() 获取标签主体中的所有 TagField 实例,并使用 Groovy each() 方法迭代该实例列表。

第 27 行将每个 tagField.id 的计数累积到 tagFieldIdCounts 映射中。

最后,第 32-24 行迭代 tagFieldIdCounts 映射,打印出键(找到的标签字段 ID)和值(每个标签字段 ID 的出现次数计数)。

我按如下方式运行此脚本

$ groovy TagAnalyzer5b.groovy > tagAnalysis5b.csv

然后我将结果加载到 LibreOfficeOnlyOffice 电子表格中。在我的例子中,这个脚本需要相当长的时间才能运行(几分钟),加载的数据按第二列(计数)降序排序后,看起来像这样

Image of a screenshot of the first few row of tagAnalysis in LibreOffic Calc

(Chris Hermansen,CC BY-SA 4.0)

在第 2 行,您可以看到 TITLE 字段标签 ID 出现了 8,696 次,这是 FLAC 文件(以及通常的 Vorbis)用于歌曲标题的 ID。在第 28 行,您还可以看到 TIT2 字段标签 ID 出现了 348 次,这是包含歌曲“实际”名称的 ID3 标签字段。此时,值得去查看 JavaDocorg.jaudiotagger.tag.ide.framebody.FrameBodyTIT2 的文档,以了解有关此标签以及 JAudiotagger 识别它的方式的更多信息。在那里,您还可以看到处理其他 ID3 标签字段的机制。

在该字段标签 ID 列表中,有很多我并不感兴趣的,并且可能会影响各种音乐播放器以我认为合理的顺序显示我的音乐收藏的能力。

org.jaudiotagger.tag.Tag 接口

我将花一点时间来探索 JAudiotagger 提供通用机制来访问标签字段的方式。此机制在 JavaDocsorg.jaudiotagger.tag.Tag 的文档中进行了描述。 有两种方法可以帮助清理标签字段的情况

void setField(FieldKey genericKey,String value)

这用于设置特定标签字段的值。

此行用于删除特定标签字段的所有实例(事实证明,某些标签方案中的某些标签字段允许多次出现)。

void deleteField(FieldKey fieldKey)

但是,这个特定的 deleteField() 方法需要我们提供一个 FieldKey 值,正如我所发现的,我的音乐收藏中并非所有字段键 ID 都对应于已知的 FieldKey 值。

查看 JavaDocs,我看到有一个 FlacTag “对其大部分元数据使用 Vorbis 注释”,并声明其标签字段类型为 VorbisCommentTag

VorbisCommentTag 本身扩展了 org.jaudiotagger.audio.generic.AbstractTag,它提供了

protected void deleteField(String key)

事实证明,这可以从 AudioFileIO.read(f).getTag() 返回的标签实例访问,至少对于 FLAC 和 MP3 标签主体而言是如此。

理论上,应该可以这样做

  1. 使用以下方法获取标签主体

    def af = AudioFileIO.read(contentFile)
    def tagBody = af.tag
  2. 使用以下方法获取我想要的(已知)标签字段的值

    def album = tagBody.getFirst(FieldKey.ALBUM)
    def artist = tagBody.getFirst(FieldKey.ARTIST)
    // etc
  3. 使用以下方法删除所有标签字段(包括想要的和不想要的)

    def originalTagFieldIdList = tagBody.fields.collect { tagField ->
    tagField.id
    }
    originalTagFieldIdList.each { tagFieldId ->
    tagBody.deleteField(tagFieldId)
    }
  4. 仅放回所需的标签字段

    tagBody.setField(FieldKey.ALBUM, album)
    tagBody.setField(FieldKey.ARTIST, artist)
    // etc

当然,这里有一些细节问题。

首先,请注意 originalTagFieldIdList 的使用。我不能在修改这些字段的同时使用 each() 来迭代 tagBody.getFields() 返回的迭代器;所以我使用 collect() 将标签字段 ID 获取到一个列表中,然后迭代该标签字段 ID 列表以进行删除。

其次,并非所有文件都将具有我想要的所有标签字段。例如,某些文件可能没有定义 ALBUM_SORT_ORDER 等等。我可能不希望将这些标签字段写入空值。此外,我可以安全地默认某些字段。例如,如果未定义 ALBUM_ARTIST,我可以将其设置为 ARTIST。

第三,对我来说最晦涩难懂的是,Vorbis 注释标签始终包含 VENDOR 字段标签 ID;如果我尝试删除它,我最终只会取消设置该值。哼。

尝试一下

考虑到这些教训,我决定创建一个测试音乐目录,其中仅包含一些艺术家及其专辑(因为我不想擦除我的音乐收藏。)

警告:由于此脚本会更改音乐标签,因此拥有音乐收藏的备份非常重要,这样当我发现我删除了一个重要的标签时,我可以恢复备份,修改脚本并重新运行它。

这是脚本

1        @Grab('net.jthink:jaudiotagger:3.0.1')
2        import org.jaudiotagger.audio.*
3        import org.jaudiotagger.tag.*
4        def logger = java.util.logging.Logger.getLogger('org.jaudiotagger');5        logger.setLevel(java.util.logging.Level.OFF);
6        // Define the music library directory
7        def musicLibraryDirName = '/work/Test/Music'
8        // Print the CSV file header
9        println "artistDir|albumDir|contentFile|tagField.id|tagField.toString()"
10        // Iterate over each directory in the music libary directory
11        // These are assumed to be artist directories
12        new File(musicLibraryDirName).eachDir { artistDir ->
13    // Iterate over each directory in the artist directory
14    // These are assumed o be album directories
15    artistDir.eachDir { albumDir ->
16    // Iterate over each file in the album directory
17    // These are assumed to be content or related18    // (cover.jpg, PDFs with liner notes etc)
19    albumDir.eachFile { contentFile ->
20        // Analyze the file and print the analysis
21        if (contentFile.name ==~ /.*\.(flac|mp3|ogg)/) {
22            def af = AudioFileIO.read(contentFile)
23            def tagBody = af.tag
24            def album = tagBody.getFirst(FieldKey.ALBUM)
25            def albumArtist = tagBody.getFirst(FieldKey.ALBUM_ARTIST)
26            def albumArtistSort = tagBody.getFirst(FieldKey.ALBUM_ARTIST_SORT)
27            def artist = tagBody.getFirst(FieldKey.ARTIST)
28            def artistSort = tagBody.getFirst(FieldKey.ARTIST_SORT)
29            def composer = tagBody.getFirst(FieldKey.COMPOSER)
30            def composerSort = tagBody.getFirst(FieldKey.COMPOSER_SORT)
31            def genre = tagBody.getFirst(FieldKey.GENRE)
32            def title = tagBody.getFirst(FieldKey.TITLE)
33            def titleSort = tagBody.getFirst(FieldKey.TITLE_SORT)
34            def track = tagBody.getFirst(FieldKey.TRACK)
35            def trackTotal = tagBody.getFirst(FieldKey.TRACK_TOTAL)
36            def year = tagBody.getFirst(FieldKey.YEAR)
37            if (!albumArtist) albumArtist = artist
38            if (!albumArtistSort) albumArtistSort = albumArtist
39            if (!artistSort) artistSort = artist
40            if (!composerSort) composerSort = composer
41            if (!titleSort) titleSort = title
42            println "${artistDir.name}|${albumDir.name}|${contentFile.name}|FieldKey.ALBUM|${album}"
43            println "${artistDir.name}|${albumDir.name}|${contentFile.name}|FieldKey.ALBUM_ARTIST|${albumArtist}"
44            println "${artistDir.name}|${albumDir.name}|${contentFile.name}|FieldKey.ALBUM_ARTIST_SORT|${albumArtistSort}"
45            println "${artistDir.name}|${albumDir.name}|${contentFile.name}|FieldKey.ARTIST|${artist}"
46            println "${artistDir.name}|${albumDir.name}|${contentFile.name}|FieldKey.ARTIST_SORT|${artistSort}"
47            println "${artistDir.name}|${albumDir.name}|${contentFile.name}|FieldKey.COMPOSER|${composer}"
48            println "${artistDir.name}|${albumDir.name}|${contentFile.name}
|FieldKey.COMPOSER_SORT|${composerSort}"
49            println "${artistDir.name}|${albumDir.name}|${contentFile.name}|FieldKey.GENRE|${genre}"
50            println "${artistDir.name}|${albumDir.name}|${contentFile.name}|FieldKey.TITLE|${title}"
51            println "${artistDir.name}|${albumDir.name}|${contentFile.name}|FieldKey.TITLE_SORT|${titleSort}"
52            println "${artistDir.name}|${albumDir.name}|${contentFile.name}|FieldKey.TRACK|${track}"
53            println "${artistDir.name}|${albumDir.name}|${contentFile.name}|FieldKey.TRACK_TOTAL|${trackTotal}"
54            println "${artistDir.name}|${albumDir.name}|${contentFile.name}|FieldKey.YEAR|${year}"
55            def originalTagIdList = tagBody.fields.collect {
56                tagField -> tagField.id
57            }
58            originalTagIdList.each { tagFieldId ->
59                println "${artistDir.name}|${albumDir.name}|${contentFile.name}|${tagFieldId}|XXX"
60                if (tagFieldId != 'VENDOR')
61                    tagBody.deleteField(tagFieldId)
62            }
63            if (album) tagBody.setField(FieldKey.ALBUM, album)
64            if (albumArtist) tagBody.setField(FieldKey.ALBUM_ARTIST, albumArtist)
65            if (albumArtistSort) tagBody.setField(FieldKey.ALBUM_ARTIST_SORT, albumArtistSort)
66            if (artist) tagBody.setField(FieldKey.ARTIST, artist)
67            if (artistSort) tagBody.setField(FieldKey.ARTIST_SORT, artistSort)
68            if (composer) tagBody.setField(FieldKey.COMPOSER, composer)
69            if (composerSort) tagBody.setField(FieldKey.COMPOSER_SORT, composerSort)
70            if (genre) tagBody.setField(FieldKey.GENRE, genre)
71            if (title) tagBody.setField(FieldKey.TITLE, title)
72            if (titleSort) tagBody.setField(FieldKey.TITLE_SORT, titleSort)
73            if (track) tagBody.setField(FieldKey.TRACK, track)
74            if (trackTotal) tagBody.setField(FieldKey.TRACK_TOTAL, trackTotal)
75            if (year) tagBody.setField(FieldKey.YEAR, year)
76            af.commit()77        }
78      }
79    }
80  }

第 1-21 行已经很熟悉了。请注意,我在第 7 行中定义的音乐目录指的是一个测试目录!

第 22-23 行获取标签主体。

第 24-36 行获取我感兴趣的字段(但可能不是您感兴趣的字段,所以请随意根据您自己的需求进行调整!)

第 37-41 行调整一些缺失的 ALBUM_ARTIST 和排序顺序的值。

第 42-54 行打印出每个标签字段键和调整后的值以供参考。

第 55-57 行获取所有标签字段 ID 的列表。

第 58-62 行打印出每个标签字段 id 并删除它,VENDOR 标签字段 ID 除外。

第 63-75 行使用已知的标签字段键设置所需的标签字段值。

最后,第 76 行将更改提交到文件

该脚本生成可以导入到电子表格中的输出。

我只想再次提及,这个脚本会更改音乐标签!拥有音乐收藏的备份非常重要,这样当您发现您删除了一个重要的标签,或者以某种方式损坏了您的音乐文件时,您可以恢复备份,修改脚本并重新运行它。

使用这个 Groovy 脚本检查结果

我有一个方便的 Groovy 小脚本来检查结果

1        @Grab('net.jthink:jaudiotagger:3.0.1')
2        import org.jaudiotagger.audio.*
3        import org.jaudiotagger.tag.*
  
4        def logger = java.util.logging.Logger.getLogger('org.jaudiotagger');
5        logger.setLevel(java.util.logging.Level.OFF);
  
6        // Define the music libary directory
  
7        def musicLibraryDirName = '/work/Test/Music'
  
8        // Print the CSV file header
  
9        println "artistDir|albumDir|tagField.id|tagField.toString()"
  
10        // Iterate over each directory in the music libary directory
11        // These are assumed to be artist directories
  
12        new File(musicLibraryDirName).eachDir { artistDir ->
  
13            // Iterate over each directory in the artist directory
14            // These are assumed to be album directories
  
15            artistDir.eachDir { albumDir ->
  
16                // Iterate over each file in the album directory
17                // These are assumed to be content or related
18                // (cover.jpg, PDFs with liner notes etc)
  
19                albumDir.eachFile { contentFile ->
  
20                    // Analyze the file and print the analysis
  
21                    if (contentFile.name ==~ /.*\.(flac|mp3|ogg)/) {
22                        def af = AudioFileIO.read(contentFile)
23                        af.tag.fields.each { tagField ->
24                            println "${artistDir.name}|${albumDir.name}|${tagField.id}|${tagField.toString()}"
25                        }
26                    }
  
27                }
28            }
29        }

到目前为止,这应该看起来很熟悉!

运行它会产生这样的结果,在上一节中运行修复脚本之前

St Germain|Tourist|VENDOR|reference libFLAC 1.1.4 20070213
St Germain|Tourist|TITLE|Land Of...
St Germain|Tourist|ARTIST|St Germain
St Germain|Tourist|ALBUM|Tourist
St Germain|Tourist|TRACKNUMBER|04
St Germain|Tourist|TRACKTOTAL|09
St Germain|Tourist|GENRE|Electronica
St Germain|Tourist|DISCID|730e0809
St Germain|Tourist|MUSICBRAINZ_DISCID|jdWlcpnr5MSZE9H0eibpRfeZtt0-
St Germain|Tourist|MUSICBRAINZ_SORTNAME|St Germain

一旦运行修复脚本,它会产生这样的结果

St Germain|Tourist|VENDOR|reference libFLAC 1.1.4 20070213
St Germain|Tourist|ALBUM|Tourist
St Germain|Tourist|ALBUMARTIST|St Germain
St Germain|Tourist|ALBUMARTISTSORT|St Germain
St Germain|Tourist|ARTIST|St Germain
St Germain|Tourist|ARTISTSORT|St Germain
St Germain|Tourist|GENRE|Electronica
St Germain|Tourist|TITLE|Land Of...
St Germain|Tourist|TITLESORT|Land Of...
St Germain|Tourist|TRACKNUMBER|04
St Germain|Tourist|TRACKTOTAL|09

就这样!现在我只需要鼓起勇气在我的完整音乐库上运行我的修复脚本了……

Chris Hermansen portrait Temuco Chile
自从 1978 年毕业于不列颠哥伦比亚大学以来,我几乎离不开计算机。从 2005 年起,我成为一名全职 Linux 用户,从 1986 年到 2005 年是一名全职 Solaris 和 SunOS 用户,在此之前是 UNIX System V 用户。

评论已关闭。

Creative Commons 许可协议本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。
© . All rights reserved.