From 742fd16a0122eb1b2f4e601ea9d18b4c1bb248f4 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 3 Dec 2023 19:19:16 +0000 Subject: [PATCH] Parse more itunes keys, optimize taglib wrapper (#2680) * parse more itunes keys * Move special iTunes M4A logic to Go code * Simplify ASF/WMA tags handling * Simplify ASF/WMA tags handling even more, moving compilation logic to `metadata` normalizer * Remove strdups from C++ code, `C.GoString` already duplicates the strings * reduced set * remove strdup * Small nitpick --------- Co-authored-by: Deluan --- scanner/metadata/metadata.go | 2 +- scanner/metadata/metadata_test.go | 15 +++- scanner/metadata/taglib/taglib_test.go | 14 +++- scanner/metadata/taglib/taglib_wrapper.cpp | 68 ++++++------------ scanner/metadata/taglib/taglib_wrapper.go | 23 +++++- scanner/metadata/taglib/taglib_wrapper.h | 1 + scanner/tag_scanner_test.go | 3 +- scanner/walk_dir_tree_test.go | 2 +- .../01 Invisible (RED) Edit Version.m4a | Bin 18051 -> 18051 bytes tests/fixtures/test.m4a | Bin 0 -> 18051 bytes 10 files changed, 70 insertions(+), 58 deletions(-) create mode 100644 tests/fixtures/test.m4a diff --git a/scanner/metadata/metadata.go b/scanner/metadata/metadata.go index 1d3b12fd0d7..e18d1b341b2 100644 --- a/scanner/metadata/metadata.go +++ b/scanner/metadata/metadata.go @@ -107,7 +107,7 @@ func (t Tags) Comment() string { return t.getFirstTagValue("comment" func (t Tags) Lyrics() string { return t.getFirstTagValue("lyrics", "lyrics-eng", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics") } -func (t Tags) Compilation() bool { return t.getBool("tcmp", "compilation") } +func (t Tags) Compilation() bool { return t.getBool("tcmp", "compilation", "wm/iscompilation") } func (t Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") } func (t Tags) DiscNumber() (int, int) { return t.getTuple("disc", "discnumber") } func (t Tags) DiscSubtitle() string { diff --git a/scanner/metadata/metadata_test.go b/scanner/metadata/metadata_test.go index dba52ed7739..be275500984 100644 --- a/scanner/metadata/metadata_test.go +++ b/scanner/metadata/metadata_test.go @@ -16,9 +16,9 @@ var _ = Describe("Tags", func() { }) It("correctly parses metadata from all files in folder", func() { - mds, err := metadata.Extract("tests/fixtures/test.mp3", "tests/fixtures/test.ogg") + mds, err := metadata.Extract("tests/fixtures/test.mp3", "tests/fixtures/test.ogg", "tests/fixtures/test.wma") Expect(err).NotTo(HaveOccurred()) - Expect(mds).To(HaveLen(2)) + Expect(mds).To(HaveLen(3)) m := mds["tests/fixtures/test.mp3"] Expect(m.Title()).To(Equal("Song")) @@ -65,6 +65,17 @@ var _ = Describe("Tags", func() { // TabLib 1.12 returns 18, previous versions return 39. // See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b Expect(m.BitRate()).To(BeElementOf(18, 39, 40, 49)) + + m = mds["tests/fixtures/test.wma"] + Expect(err).To(BeNil()) + Expect(m.Compilation()).To(BeTrue()) + Expect(m.Title()).To(Equal("Title")) + Expect(m.HasPicture()).To(BeFalse()) + Expect(m.Duration()).To(BeNumerically("~", 1.02, 0.01)) + Expect(m.Suffix()).To(Equal("wma")) + Expect(m.FilePath()).To(Equal("tests/fixtures/test.wma")) + Expect(m.Size()).To(Equal(int64(21431))) + Expect(m.BitRate()).To(BeElementOf(128)) }) }) }) diff --git a/scanner/metadata/taglib/taglib_test.go b/scanner/metadata/taglib/taglib_test.go index bc0cfd6309d..ed4b5034e5a 100644 --- a/scanner/metadata/taglib/taglib_test.go +++ b/scanner/metadata/taglib/taglib_test.go @@ -72,7 +72,6 @@ var _ = Describe("Extractor", func() { Expect(m).To(HaveKey("bitrate")) Expect(m["bitrate"][0]).To(BeElementOf("18", "39", "40", "49")) }) - DescribeTable("Format-Specific tests", func(file, duration, channels, albumGain, albumPeak, trackGain, trackPeak string) { file = "tests/fixtures/" + file @@ -91,15 +90,24 @@ var _ = Describe("Extractor", func() { Expect(m).To(HaveKeyWithValue("album", []string{"Album", "Album"})) Expect(m).To(HaveKeyWithValue("artist", []string{"Artist", "Artist"})) Expect(m).To(HaveKeyWithValue("albumartist", []string{"Album Artist"})) - Expect(m).To(HaveKeyWithValue("compilation", []string{"1"})) Expect(m).To(HaveKeyWithValue("genre", []string{"Rock"})) Expect(m).To(HaveKeyWithValue("date", []string{"2014", "2014"})) + // Special for M4A, do not catch keys that have no actual name + Expect(m).ToNot(HaveKey("")) + Expect(m).To(HaveKey("discnumber")) discno := m["discnumber"] Expect(discno).To(HaveLen(1)) Expect(discno[0]).To(BeElementOf([]string{"1", "1/2"})) + // WMA does not have a "compilation" tag, but "wm/iscompilation" + if _, ok := m["compilation"]; ok { + Expect(m).To(HaveKeyWithValue("compilation", []string{"1"})) + } else { + Expect(m).To(HaveKeyWithValue("wm/iscompilation", []string{"1"})) + } + Expect(m).NotTo(HaveKeyWithValue("has_picture", []string{"true"})) Expect(m).To(HaveKeyWithValue("duration", []string{duration})) @@ -118,6 +126,7 @@ var _ = Describe("Extractor", func() { Entry("correctly parses flac tags", "test.flac", "1.00", "1", "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948"), Entry("Correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04", "2", "0.37", "0.48", "0.37", "0.48"), + Entry("Correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04", "2", "0.37", "0.48", "0.37", "0.48"), Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04", "2", "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506"), @@ -133,7 +142,6 @@ var _ = Describe("Extractor", func() { // ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff //Entry("correctly parses aiff tags", "test.aiff", "1.00", "1", "2.00 dB", "0.124972", "2.00 dB", "0.124972"), - ) }) diff --git a/scanner/metadata/taglib/taglib_wrapper.cpp b/scanner/metadata/taglib/taglib_wrapper.cpp index b5ea6056962..6cc77f56d8f 100644 --- a/scanner/metadata/taglib/taglib_wrapper.cpp +++ b/scanner/metadata/taglib/taglib_wrapper.cpp @@ -15,13 +15,6 @@ #include "taglib_wrapper.h" -// Tags necessary for M4a parsing -const char *RG_TAGS[] = { - "replaygain_album_gain", - "replaygain_album_peak", - "replaygain_track_gain", - "replaygain_track_peak"}; - char has_cover(const TagLib::FileRef f); int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) { @@ -42,6 +35,7 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) { go_map_put_int(id, (char *)"bitrate", props->bitrate()); go_map_put_int(id, (char *)"channels", props->channels()); + // Create a map to collect all the tags TagLib::PropertyMap tags = f.file()->properties(); // Make sure at least the basic properties are extracted @@ -77,71 +71,49 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) { } } + // M4A may have some iTunes specific tags TagLib::MP4::File *m4afile(dynamic_cast(f.file())); - if (m4afile != NULL) - { - const auto itemListMap = m4afile->tag(); - { - char buf[200]; - - for (const char *key : RG_TAGS) - { - snprintf(buf, sizeof(buf), "----:com.apple.iTunes:%s", key); - const auto item = itemListMap->item(buf); - if (item.isValid()) - { - char *dup = ::strdup(key); - char *val = ::strdup(item.toStringList().front().toCString(true)); - go_map_put_str(id, dup, val); - free(dup); - free(val); - } + if (m4afile != NULL) { + const auto itemListMap = m4afile->tag()->itemMap(); + for (const auto item: itemListMap) { + char *key = (char *)item.first.toCString(true); + for (const auto value: item.second.toStringList()) { + char *val = (char *)value.toCString(true); + go_map_put_m4a_str(id, key, val); } } } // WMA/ASF files may have additional tags not captured by the general iterator TagLib::ASF::File *asfFile(dynamic_cast(f.file())); - if (asfFile != NULL) - { + if (asfFile != NULL) { const TagLib::ASF::Tag *asfTags{asfFile->tag()}; const auto itemListMap = asfTags->attributeListMap(); for (const auto item : itemListMap) { - char *key = ::strdup(item.first.toCString(true)); - char *val = ::strdup(item.second.front().toString().toCString()); - go_map_put_str(id, key, val); - free(key); - free(val); - } - - // Compilation tag needs to be handled differently - const auto compilation = asfTags->attribute("WM/IsCompilation"); - if (!compilation.isEmpty()) { - char *val = ::strdup(compilation.front().toString().toCString()); - go_map_put_str(id, (char *)"compilation", val); - free(val); + tags.insert(item.first, item.second.front().toString()); } } - if (has_cover(f)) { - go_map_put_str(id, (char *)"has_picture", (char *)"true"); - } - + // Send all collected tags to the Go map for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end(); ++i) { + char *key = (char *)i->first.toCString(true); for (TagLib::StringList::ConstIterator j = i->second.begin(); j != i->second.end(); ++j) { - char *key = ::strdup(i->first.toCString(true)); - char *val = ::strdup((*j).toCString(true)); + char *val = (char *)(*j).toCString(true); go_map_put_str(id, key, val); - free(key); - free(val); } } + // Cover art has to be handled separately + if (has_cover(f)) { + go_map_put_str(id, (char *)"has_picture", (char *)"true"); + } + return 0; } +// Detect if the file has cover art. Returns 1 if the file has cover art, 0 otherwise. char has_cover(const TagLib::FileRef f) { char hasCover = 0; // ----- MP3 diff --git a/scanner/metadata/taglib/taglib_wrapper.go b/scanner/metadata/taglib/taglib_wrapper.go index 55bbe8fe876..e5c6d9c2086 100644 --- a/scanner/metadata/taglib/taglib_wrapper.go +++ b/scanner/metadata/taglib/taglib_wrapper.go @@ -23,6 +23,8 @@ import ( "github.com/navidrome/navidrome/log" ) +const iTunesKeyPrefix = "----:com.apple.itunes:" + func Read(filename string) (tags map[string][]string, err error) { // Do not crash on failures in the C code/library debug.SetPanicOnFault(true) @@ -79,14 +81,31 @@ func deleteMap(id uint32) { delete(maps, id) } +//export go_map_put_m4a_str +func go_map_put_m4a_str(id C.ulong, key *C.char, val *C.char) { + k := strings.ToLower(C.GoString(key)) + + // Special for M4A, do not catch keys that have no actual name + k = strings.TrimPrefix(k, iTunesKeyPrefix) + do_put_map(id, k, val) +} + //export go_map_put_str func go_map_put_str(id C.ulong, key *C.char, val *C.char) { + k := strings.ToLower(C.GoString(key)) + do_put_map(id, k, val) +} + +func do_put_map(id C.ulong, key string, val *C.char) { + if key == "" { + return + } + lock.RLock() defer lock.RUnlock() m := maps[uint32(id)] - k := strings.ToLower(C.GoString(key)) v := strings.TrimSpace(C.GoString(val)) - m[k] = append(m[k], v) + m[key] = append(m[key], v) } //export go_map_put_int diff --git a/scanner/metadata/taglib/taglib_wrapper.h b/scanner/metadata/taglib/taglib_wrapper.h index d80ac1f83f7..5625d2fa8b9 100644 --- a/scanner/metadata/taglib/taglib_wrapper.h +++ b/scanner/metadata/taglib/taglib_wrapper.h @@ -11,6 +11,7 @@ extern "C" { #define FILENAME_CHAR_T char #endif +extern void go_map_put_m4a_str(unsigned long id, char *key, char *val); extern void go_map_put_str(unsigned long id, char *key, char *val); extern void go_map_put_int(unsigned long id, char *key, int val); int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id); diff --git a/scanner/tag_scanner_test.go b/scanner/tag_scanner_test.go index a798570c06e..5629d221938 100644 --- a/scanner/tag_scanner_test.go +++ b/scanner/tag_scanner_test.go @@ -10,9 +10,10 @@ var _ = Describe("TagScanner", func() { It("return all audio files from the folder", func() { files, err := loadAllAudioFiles("tests/fixtures") Expect(err).ToNot(HaveOccurred()) - Expect(files).To(HaveLen(10)) + Expect(files).To(HaveLen(11)) Expect(files).To(HaveKey("tests/fixtures/test.aiff")) Expect(files).To(HaveKey("tests/fixtures/test.flac")) + Expect(files).To(HaveKey("tests/fixtures/test.m4a")) Expect(files).To(HaveKey("tests/fixtures/test.mp3")) Expect(files).To(HaveKey("tests/fixtures/test.ogg")) Expect(files).To(HaveKey("tests/fixtures/test.wav")) diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index a7d68f4a057..45b0dff5606 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -34,7 +34,7 @@ var _ = Describe("walk_dir_tree", func() { Expect(collected[baseDir]).To(MatchFields(IgnoreExtras, Fields{ "Images": BeEmpty(), "HasPlaylist": BeFalse(), - "AudioFilesCount": BeNumerically("==", 11), + "AudioFilesCount": BeNumerically("==", 12), })) Expect(collected[filepath.Join(baseDir, "artist", "an-album")]).To(MatchFields(IgnoreExtras, Fields{ "Images": ConsistOf("cover.jpg", "front.png", "artist.png"), diff --git a/tests/fixtures/01 Invisible (RED) Edit Version.m4a b/tests/fixtures/01 Invisible (RED) Edit Version.m4a index 80474aa35150c1ff913d3564c5dfb1618348dae6..eb608b9b04ba934ff16a170a554b626ba0fb845f 100644 GIT binary patch delta 77 zcmZqfWo+(c+^~?DF>~`GW*tUG*U1yvRAhPb5_3}-AV4G~u_O`5WCU?TQj1F_H?lfy Z=4IW%#Oj$=l$yHvBD*v5<{)lEYXA|$6e<7! delta 49 zcmZqfWo+(c+^~?DQDyTYW*x@KDr}aM`PkVu2eIy8V(m*SN=@A?$l=I5S%JfSa}c+o FH2`>g4om<5 diff --git a/tests/fixtures/test.m4a b/tests/fixtures/test.m4a new file mode 100644 index 0000000000000000000000000000000000000000..81cdde6a2566c687161caa5a527b8dbeeaf4c50b GIT binary patch literal 18051 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 literal 0 HcmV?d00001