diff --git a/.gitignore b/.gitignore index 8f9bbdae..27c02da3 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,5 @@ AGENTS.md *.test *.wasm *.ndp -openspec/ \ No newline at end of file +openspec/ +go.work* \ No newline at end of file diff --git a/adapters/gotaglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go new file mode 100644 index 00000000..4a93f5b8 --- /dev/null +++ b/adapters/gotaglib/end_to_end_test.go @@ -0,0 +1,274 @@ +package gotaglib + +import ( + "io/fs" + "os" + "time" + + "github.com/djherbis/times" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/metadata" + "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type testFileInfo struct { + fs.FileInfo +} + +func (t testFileInfo) BirthTime() time.Time { + if ts := times.Get(t.FileInfo); ts.HasBirthTime() { + return ts.BirthTime() + } + return t.FileInfo.ModTime() +} + +var _ = Describe("Extractor", func() { + toP := func(name, sortName, mbid string) model.Participant { + return model.Participant{ + Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid}, + } + } + + roles := []struct { + model.Role + model.ParticipantList + }{ + {model.RoleComposer, model.ParticipantList{ + toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"), + toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"), + }}, + {model.RoleLyricist, model.ParticipantList{ + toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"), + toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"), + }}, + {model.RoleArranger, model.ParticipantList{ + toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"), + toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"), + }}, + {model.RoleConductor, model.ParticipantList{ + toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"), + toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"), + }}, + {model.RoleDirector, model.ParticipantList{ + toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"), + toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"), + }}, + {model.RoleEngineer, model.ParticipantList{ + toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"), + toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"), + }}, + {model.RoleProducer, model.ParticipantList{ + toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"), + toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"), + }}, + {model.RoleRemixer, model.ParticipantList{ + toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"), + toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"), + }}, + {model.RoleDJMixer, model.ParticipantList{ + toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"), + toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"), + }}, + {model.RoleMixer, model.ParticipantList{ + toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"), + toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"), + }}, + } + + var e *extractor + + parseTestFile := func(path string) *model.MediaFile { + mds, err := e.Parse(path) + Expect(err).ToNot(HaveOccurred()) + + info, ok := mds[path] + Expect(ok).To(BeTrue()) + + fileInfo, err := os.Stat(path) + Expect(err).ToNot(HaveOccurred()) + info.FileInfo = testFileInfo{FileInfo: fileInfo} + + metadata := metadata.New(path, info) + mf := metadata.ToMediaFile(1, "folderID") + return &mf + } + + BeforeEach(func() { + e = &extractor{fs: os.DirFS(".")} + }) + + Describe("ReplayGain", func() { + DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) { + mf := parseTestFile("tests/fixtures/" + file) + + Expect(mf.RGTrackGain).To(Equal(trackGain)) + Expect(mf.RGTrackPeak).To(Equal(trackPeak)) + Expect(mf.RGAlbumGain).To(Equal(albumGain)) + Expect(mf.RGAlbumPeak).To(Equal(albumPeak)) + }, + Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil), + Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)), + ) + }) + + Describe("lyrics", func() { + makeLyrics := func(code, secondLine string) model.Lyrics { + return model.Lyrics{ + DisplayArtist: "", + DisplayTitle: "", + Lang: code, + Line: []model.Line{ + {Start: gg.P(int64(0)), Value: "This is"}, + {Start: gg.P(int64(2500)), Value: secondLine}, + }, + Offset: nil, + Synced: true, + } + } + + It("should fetch both synced and unsynced lyrics in mixed flac", func() { + mf := parseTestFile("tests/fixtures/mixed-lyrics.flac") + + lyrics, err := mf.StructuredLyrics() + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(2)) + + Expect(lyrics[0].Synced).To(BeTrue()) + Expect(lyrics[1].Synced).To(BeFalse()) + }) + + It("should handle mp3 with uslt and sylt", func() { + mf := parseTestFile("tests/fixtures/test.mp3") + + lyrics, err := mf.StructuredLyrics() + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(4)) + + engSylt := makeLyrics("eng", "English SYLT") + engUslt := makeLyrics("eng", "English") + unsSylt := makeLyrics("xxx", "unspecified SYLT") + unsUslt := makeLyrics("xxx", "unspecified") + + Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt)) + }) + + DescribeTable("format-specific lyrics", func(file string, isId3 bool) { + mf := parseTestFile("tests/fixtures/" + file) + + lyrics, err := mf.StructuredLyrics() + Expect(err).To(Not(HaveOccurred())) + Expect(lyrics).To(HaveLen(2)) + + unspec := makeLyrics("xxx", "unspecified") + eng := makeLyrics("xxx", "English") + + if isId3 { + eng.Lang = "eng" + } + + Expect(lyrics).To(Or( + Equal(model.LyricList{unspec, eng}), + Equal(model.LyricList{eng, unspec}))) + }, + Entry("flac", "test.flac", false), + Entry("m4a", "test.m4a", false), + Entry("ogg", "test.ogg", false), + Entry("wma", "test.wma", false), + Entry("wv", "test.wv", false), + Entry("wav", "test.wav", true), + Entry("aiff", "test.aiff", true), + ) + }) + + Describe("Participants", func() { + DescribeTable("test tags consistent across formats", func(format string) { + mf := parseTestFile("tests/fixtures/test." + format) + + for _, data := range roles { + role := data.Role + artists := data.ParticipantList + + actual := mf.Participants[role] + Expect(actual).To(HaveLen(len(artists))) + + for i := range artists { + actualArtist := actual[i] + expectedArtist := artists[i] + + Expect(actualArtist.Name).To(Equal(expectedArtist.Name)) + Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName)) + Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID)) + } + } + + if format != "m4a" { + performers := mf.Participants[model.RolePerformer] + Expect(performers).To(HaveLen(8)) + + rules := map[string][]string{ + "pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"}, + "pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""}, + "pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"}, + "pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"}, + "pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"}, + } + + for name, rule := range rules { + mbid := rule[0] + for i := 1; i < len(rule); i++ { + found := false + + for _, mapped := range performers { + if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] { + found = true + break + } + } + + Expect(found).To(BeTrue(), "Could not find matching artist") + } + } + } + }, + Entry("FLAC format", "flac"), + Entry("M4a format", "m4a"), + Entry("OGG format", "ogg"), + Entry("WV format", "wv"), + + Entry("MP3 format", "mp3"), + Entry("WAV format", "wav"), + Entry("AIFF format", "aiff"), + ) + + It("should parse wma", func() { + mf := parseTestFile("tests/fixtures/test.wma") + + for _, data := range roles { + role := data.Role + artists := data.ParticipantList + actual := mf.Participants[role] + + // WMA has no Arranger role + if role == model.RoleArranger { + Expect(actual).To(HaveLen(0)) + continue + } + + Expect(actual).To(HaveLen(len(artists)), role.String()) + + // For some bizarre reason, the order is inverted. We also don't get + // sort names or MBIDs + for i := range artists { + idx := len(artists) - 1 - i + + actualArtist := actual[i] + expectedArtist := artists[idx] + + Expect(actualArtist.Name).To(Equal(expectedArtist.Name)) + } + } + }) + }) +}) diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go new file mode 100644 index 00000000..46c5fa0e --- /dev/null +++ b/adapters/gotaglib/gotaglib.go @@ -0,0 +1,263 @@ +// Package gotaglib provides an alternative metadata extractor using go-taglib, +// a pure Go (WASM-based) implementation of TagLib. +// +// This extractor aims for parity with the CGO-based taglib extractor. It uses +// TagLib's PropertyMap interface for standard tags. The File handle API provides +// efficient access to format-specific tags (ID3v2 frames, MP4 atoms, ASF attributes) +// through a single file open operation. +// +// This extractor is registered under the name "gotaglib". It only works with a filesystem +// (fs.FS) and does not support direct local file paths. Files returned by the filesystem +// must implement io.ReadSeeker for go-taglib to read them. +package gotaglib + +import ( + "errors" + "io" + "io/fs" + "strings" + "time" + + "github.com/navidrome/navidrome/core/storage/local" + "github.com/navidrome/navidrome/model/metadata" + "go.senan.xyz/taglib" +) + +type extractor struct { + fs fs.FS +} + +func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) { + results := make(map[string]metadata.Info) + for _, path := range files { + props, err := e.extractMetadata(path) + if err != nil { + continue + } + results[path] = *props + } + return results, nil +} + +func (e extractor) Version() string { + return "go-taglib (TagLib 2.1.1 WASM)" +} + +func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { + f, close, err := e.openFile(filePath) + if err != nil { + return nil, err + } + defer close() + + // Get all tags and properties in one go + allTags := f.AllTags() + props := f.Properties() + + // Map properties to AudioProperties + ap := metadata.AudioProperties{ + Duration: props.Length.Round(time.Millisecond * 10), + BitRate: int(props.Bitrate), + Channels: int(props.Channels), + SampleRate: int(props.SampleRate), + BitDepth: int(props.BitsPerSample), + } + + // Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys) + normalizedTags := make(map[string][]string, len(allTags.Tags)) + for key, values := range allTags.Tags { + lowerKey := strings.ToLower(key) + normalizedTags[lowerKey] = values + } + + // Process format-specific raw tags + processRawTags(allTags, normalizedTags) + + // Parse track/disc totals from "N/Total" format + parseTuple(normalizedTags, "track") + parseTuple(normalizedTags, "disc") + + // Adjust some ID3 tags + parseLyrics(normalizedTags) + parseTIPL(normalizedTags) + delete(normalizedTags, "tmcl") // TMCL is already parsed by TagLib + + // Determine if file has embedded picture + hasPicture := len(props.Images) > 0 + + return &metadata.Info{ + Tags: normalizedTags, + AudioProperties: ap, + HasPicture: hasPicture, + }, nil +} + +// openFile opens the file at filePath using the extractor's filesystem. +// It returns a TagLib File handle and a cleanup function to close resources. +func (e extractor) openFile(filePath string) (*taglib.File, func(), error) { + // Open the file from the filesystem + file, err := e.fs.Open(filePath) + if err != nil { + return nil, nil, err + } + rs, isSeekable := file.(io.ReadSeeker) + if !isSeekable { + file.Close() + return nil, nil, errors.New("file is not seekable") + } + f, err := taglib.OpenStream(rs) + if err != nil { + file.Close() + return nil, nil, err + } + closeFunc := func() { + f.Close() + file.Close() + } + return f, closeFunc, nil +} + +// parseTuple parses track/disc numbers in "N/Total" format and separates them. +// For example, tracknumber="2/10" becomes tracknumber="2" and tracktotal="10". +func parseTuple(tags map[string][]string, prop string) { + tagName := prop + "number" + tagTotal := prop + "total" + if value, ok := tags[tagName]; ok && len(value) > 0 { + parts := strings.Split(value[0], "/") + tags[tagName] = []string{parts[0]} + if len(parts) == 2 { + tags[tagTotal] = []string{parts[1]} + } + } +} + +// parseLyrics ensures lyrics tags have a language code. +// If lyrics exist without a language code, they are moved to "lyrics:xxx". +func parseLyrics(tags map[string][]string) { + lyrics := tags["lyrics"] + if len(lyrics) > 0 { + tags["lyrics:xxx"] = lyrics + delete(tags, "lyrics") + } +} + +// processRawTags processes format-specific raw tags based on the detected file format. +// This handles ID3v2 frames (MP3/WAV/AIFF), MP4 atoms, and ASF attributes. +func processRawTags(allTags taglib.AllTags, normalizedTags map[string][]string) { + switch allTags.Format { + case taglib.FormatMPEG, taglib.FormatWAV, taglib.FormatAIFF: + parseID3v2Frames(allTags.Raw, normalizedTags) + case taglib.FormatMP4: + parseMP4Atoms(allTags.Raw, normalizedTags) + case taglib.FormatASF: + parseASFAttributes(allTags.Raw, normalizedTags) + } +} + +// parseID3v2Frames processes ID3v2 raw frames to extract USLT/SYLT with language codes. +// This extracts language-specific lyrics that the standard Tags() doesn't provide. +func parseID3v2Frames(rawFrames map[string][]string, tags map[string][]string) { + // Process frames that have language-specific data + for key, values := range rawFrames { + lowerKey := strings.ToLower(key) + + // Handle USLT:xxx and SYLT:xxx (lyrics with language codes) + if strings.HasPrefix(lowerKey, "uslt:") || strings.HasPrefix(lowerKey, "sylt:") { + parts := strings.SplitN(lowerKey, ":", 2) + if len(parts) == 2 && parts[1] != "" { + lang := parts[1] + lyricsKey := "lyrics:" + lang + tags[lyricsKey] = append(tags[lyricsKey], values...) + } + } + } + + // If we found any language-specific lyrics from ID3v2 frames, remove the generic lyrics + for key := range tags { + if strings.HasPrefix(key, "lyrics:") && key != "lyrics" { + delete(tags, "lyrics") + break + } + } +} + +const iTunesKeyPrefix = "----:com.apple.iTunes:" + +// parseMP4Atoms processes MP4 raw atoms to get iTunes-specific tags. +func parseMP4Atoms(rawAtoms map[string][]string, tags map[string][]string) { + // Process all atoms and add them to tags + for key, values := range rawAtoms { + // Strip iTunes prefix and convert to lowercase + normalizedKey := strings.TrimPrefix(key, iTunesKeyPrefix) + normalizedKey = strings.ToLower(normalizedKey) + + // Only add if the tag doesn't already exist (avoid duplication with PropertyMap) + if _, exists := tags[normalizedKey]; !exists { + tags[normalizedKey] = values + } + } +} + +// parseASFAttributes processes ASF raw attributes to get WMA-specific tags. +func parseASFAttributes(rawAttrs map[string][]string, tags map[string][]string) { + // Process all attributes and add them to tags + for key, values := range rawAttrs { + normalizedKey := strings.ToLower(key) + + // Only add if the tag doesn't already exist (avoid duplication with PropertyMap) + if _, exists := tags[normalizedKey]; !exists { + tags[normalizedKey] = values + } + } +} + +// These are the only roles we support, based on Picard's tag map: +// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html +var tiplMapping = map[string]string{ + "arranger": "arranger", + "engineer": "engineer", + "producer": "producer", + "mix": "mixer", + "DJ-mix": "djmixer", +} + +// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format: +// +// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson". +// +// and breaks it down into a map of roles and names, e.g.: +// +// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}. +func parseTIPL(tags map[string][]string) { + tipl := tags["tipl"] + if len(tipl) == 0 { + return + } + addRole := func(currentRole string, currentValue []string) { + if currentRole != "" && len(currentValue) > 0 { + role := tiplMapping[currentRole] + tags[role] = append(tags[role], strings.Join(currentValue, " ")) + } + } + var currentRole string + var currentValue []string + for _, part := range strings.Split(tipl[0], " ") { + if _, ok := tiplMapping[part]; ok { + addRole(currentRole, currentValue) + currentRole = part + currentValue = nil + continue + } + currentValue = append(currentValue, part) + } + addRole(currentRole, currentValue) + delete(tags, "tipl") +} + +var _ local.Extractor = (*extractor)(nil) + +func init() { + local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor { + return &extractor{fsys} + }) +} diff --git a/adapters/gotaglib/gotaglib_suite_test.go b/adapters/gotaglib/gotaglib_suite_test.go new file mode 100644 index 00000000..cc7ddc47 --- /dev/null +++ b/adapters/gotaglib/gotaglib_suite_test.go @@ -0,0 +1,17 @@ +package gotaglib + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGoTagLib(t *testing.T) { + tests.Init(t, true) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "GoTagLib Suite") +} diff --git a/adapters/gotaglib/gotaglib_test.go b/adapters/gotaglib/gotaglib_test.go new file mode 100644 index 00000000..529a8110 --- /dev/null +++ b/adapters/gotaglib/gotaglib_test.go @@ -0,0 +1,302 @@ +package gotaglib + +import ( + "io/fs" + "os" + "strings" + + "github.com/navidrome/navidrome/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Extractor", func() { + var e *extractor + + BeforeEach(func() { + e = &extractor{fs: os.DirFS(".")} + }) + + Describe("Parse", func() { + It("correctly parses metadata from all files in folder", func() { + mds, err := e.Parse( + "tests/fixtures/test.mp3", + "tests/fixtures/test.ogg", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(mds).To(HaveLen(2)) + + // Test MP3 + m := mds["tests/fixtures/test.mp3"] + Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"})) + Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"})) + Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"})) + Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"})) + + Expect(m.HasPicture).To(BeTrue()) + Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s")) + Expect(m.AudioProperties.BitRate).To(Equal(192)) + Expect(m.AudioProperties.Channels).To(Equal(2)) + Expect(m.AudioProperties.SampleRate).To(Equal(44100)) + + Expect(m.Tags).To(Or( + HaveKeyWithValue("compilation", []string{"1"}), + HaveKeyWithValue("tcmp", []string{"1"})), + ) + Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"})) + Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"})) + Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"})) + Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"})) + Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"})) + Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"})) + Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"})) + Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"})) + Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"})) + Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"})) + Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"})) + Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"})) + + Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"})) + Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"})) + + Expect(m.Tags).ToNot(HaveKey("lyrics")) + Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{ + "[00:00.00]This is\n[00:02.50]English SYLT\n", + "[00:00.00]This is\n[00:02.50]English", + }), HaveKeyWithValue("lyrics:eng", []string{ + "[00:00.00]This is\n[00:02.50]English", + "[00:00.00]This is\n[00:02.50]English SYLT\n", + }))) + Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{ + "[00:00.00]This is\n[00:02.50]unspecified SYLT\n", + "[00:00.00]This is\n[00:02.50]unspecified", + }), HaveKeyWithValue("lyrics:xxx", []string{ + "[00:00.00]This is\n[00:02.50]unspecified", + "[00:00.00]This is\n[00:02.50]unspecified SYLT\n", + }))) + + // Test OGG + m = mds["tests/fixtures/test.ogg"] + Expect(err).To(BeNil()) + Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"})) + + // TagLib 1.12 returns 18, previous versions return 39. + // See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b + Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49)) + Expect(m.AudioProperties.Channels).To(BeElementOf(2)) + Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000)) + Expect(m.HasPicture).To(BeTrue()) + }) + + DescribeTable("Format-Specific tests", + func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) { + file = "tests/fixtures/" + file + mds, err := e.Parse(file) + Expect(err).NotTo(HaveOccurred()) + Expect(mds).To(HaveLen(1)) + + m := mds[file] + + Expect(m.HasPicture).To(Equal(image)) + Expect(m.AudioProperties.Duration.String()).To(Equal(duration)) + Expect(m.AudioProperties.Channels).To(Equal(channels)) + Expect(m.AudioProperties.SampleRate).To(Equal(samplerate)) + Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth)) + + Expect(m.Tags).To(Or( + HaveKeyWithValue("replaygain_album_gain", []string{albumGain}), + HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}), + )) + + Expect(m.Tags).To(Or( + HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}), + HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}), + )) + Expect(m.Tags).To(Or( + HaveKeyWithValue("replaygain_track_gain", []string{trackGain}), + HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}), + )) + Expect(m.Tags).To(Or( + HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}), + HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}), + )) + + Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"})) + Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"})) + Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"})) + Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"})) + Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"})) + Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"})) + + Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"})) + Expect(m.Tags).To(Or( + HaveKeyWithValue("tracknumber", []string{"3"}), + HaveKeyWithValue("tracknumber", []string{"3/10"}), + )) + if !strings.HasSuffix(file, "test.wma") { + // TODO Not sure why this is not working for WMA + Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"})) + } + Expect(m.Tags).To(Or( + HaveKeyWithValue("discnumber", []string{"1"}), + HaveKeyWithValue("discnumber", []string{"1/2"}), + )) + Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"})) + + // WMA does not have a "compilation" tag, but "wm/iscompilation" + Expect(m.Tags).To(Or( + HaveKeyWithValue("compilation", []string{"1"}), + HaveKeyWithValue("wm/iscompilation", []string{"1"})), + ) + + if id3Lyrics { + Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{ + "[00:00.00]This is\n[00:02.50]English", + })) + Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{ + "[00:00.00]This is\n[00:02.50]unspecified", + })) + } else { + Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{ + "[00:00.00]This is\n[00:02.50]unspecified", + "[00:00.00]This is\n[00:02.50]English", + })) + } + + Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"})) + }, + + // ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac + Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true), + + Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true), + Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true), + Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true), + + // ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma + // Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order + Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true), + + // ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv + Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true), + + // ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav + Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true), + + // ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff + Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true), + ) + + // Skip these tests when running as root + Context("Access Forbidden", func() { + var accessForbiddenFile string + var RegularUserContext = XContext + var isRegularUser = os.Getuid() != 0 + if isRegularUser { + RegularUserContext = Context + } + + // Only run permission tests if we are not root + RegularUserContext("when run without root privileges", func() { + BeforeEach(func() { + // Use root fs for absolute paths in temp directory + e = &extractor{fs: os.DirFS("/")} + accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3") + + f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + Expect(f.Close()).To(Succeed()) + Expect(os.Remove(accessForbiddenFile)).To(Succeed()) + }) + }) + + It("correctly handle unreadable file due to insufficient read permission", func() { + // Strip leading slash for DirFS rooted at "/" + _, err := e.extractMetadata(accessForbiddenFile[1:]) + Expect(err).To(MatchError(os.ErrPermission)) + }) + + It("skips the file if it cannot be read", func() { + // Get current working directory to construct paths relative to root + cwd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + // Strip leading slash for DirFS rooted at "/" + files := []string{ + cwd[1:] + "/tests/fixtures/test.mp3", + cwd[1:] + "/tests/fixtures/test.ogg", + accessForbiddenFile[1:], + } + mds, err := e.Parse(files...) + Expect(err).NotTo(HaveOccurred()) + Expect(mds).To(HaveLen(2)) + Expect(mds).ToNot(HaveKey(accessForbiddenFile[1:])) + }) + }) + }) + + }) + + Describe("Error Checking", func() { + It("returns a generic ErrPath if file does not exist", func() { + testFilePath := "tests/fixtures/NON_EXISTENT.ogg" + _, err := e.extractMetadata(testFilePath) + Expect(err).To(MatchError(fs.ErrNotExist)) + }) + It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() { + // File has an empty TDAT frame + md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"})) + }) + }) + + Describe("parseTIPL", func() { + var tags map[string][]string + + BeforeEach(func() { + tags = make(map[string][]string) + }) + + Context("when the TIPL string is populated", func() { + It("correctly parses roles and names", func() { + tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"} + parseTIPL(tags) + Expect(tags["arranger"]).To(ConsistOf("Andrew Powell")) + Expect(tags["engineer"]).To(ConsistOf("Chris Blair")) + Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe")) + }) + + It("handles multiple names for a single role", func() { + tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"} + parseTIPL(tags) + Expect(tags["producer"]).To(ConsistOf("Eric Woolfson")) + Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair")) + }) + + It("discards roles without names", func() { + tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"} + parseTIPL(tags) + Expect(tags).ToNot(HaveKey("producer")) + Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair")) + }) + }) + + Context("when the TIPL string is empty", func() { + It("does nothing", func() { + tags["tipl"] = []string{""} + parseTIPL(tags) + Expect(tags).To(BeEmpty()) + }) + }) + + Context("when the TIPL is not present", func() { + It("does nothing", func() { + parseTIPL(tags) + Expect(tags).To(BeEmpty()) + }) + }) + }) + +}) diff --git a/adapters/taglib/end_to_end_test.go b/adapters/taglib/end_to_end_test.go index e4d94bb2..265f258f 100644 --- a/adapters/taglib/end_to_end_test.go +++ b/adapters/taglib/end_to_end_test.go @@ -151,11 +151,7 @@ var _ = Describe("Extractor", func() { unsSylt := makeLyrics("xxx", "unspecified SYLT") unsUslt := makeLyrics("xxx", "unspecified") - // Why is the order inconsistent between runs? Nobody knows - Expect(lyrics).To(Or( - Equal(model.LyricList{engSylt, engUslt, unsSylt, unsUslt}), - Equal(model.LyricList{unsSylt, unsUslt, engSylt, engUslt}), - )) + Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt)) }) DescribeTable("format-specific lyrics", func(file string, isId3 bool) { diff --git a/adapters/taglib/taglib.go b/adapters/taglib/taglib.go index d32adf4e..ac299ea2 100644 --- a/adapters/taglib/taglib.go +++ b/adapters/taglib/taglib.go @@ -168,7 +168,7 @@ func parseTIPL(tags map[string][]string) { var _ local.Extractor = (*extractor)(nil) func init() { - local.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) local.Extractor { + local.RegisterExtractor("legacy-taglib", func(_ fs.FS, baseDir string) local.Extractor { // ignores fs, as taglib extractor only works with local files return &extractor{baseDir} }) diff --git a/adapters/taglib/taglib_test.go b/adapters/taglib/taglib_test.go index f24c0e83..f524f77e 100644 --- a/adapters/taglib/taglib_test.go +++ b/adapters/taglib/taglib_test.go @@ -80,12 +80,11 @@ var _ = Describe("Extractor", func() { Expect(err).To(BeNil()) Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"})) - // TabLib 1.12 returns 18, previous versions return 39. + // TagLib 1.12 returns 18, previous versions return 39. // See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49)) Expect(m.AudioProperties.Channels).To(BeElementOf(2)) Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000)) - Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000)) Expect(m.HasPicture).To(BeTrue()) }) @@ -106,7 +105,7 @@ var _ = Describe("Extractor", func() { Expect(m.Tags).To(Or( HaveKeyWithValue("replaygain_album_gain", []string{albumGain}), - HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{albumGain}), + HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}), )) Expect(m.Tags).To(Or( diff --git a/cmd/root.go b/cmd/root.go index f5f7ff0c..74a15abc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,6 +24,7 @@ import ( // Import adapters to register them _ "github.com/navidrome/navidrome/adapters/deezer" + _ "github.com/navidrome/navidrome/adapters/gotaglib" _ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/listenbrainz" _ "github.com/navidrome/navidrome/adapters/spotify" diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index a016d7bd..7a9d38d9 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -33,6 +33,7 @@ import ( import ( _ "github.com/navidrome/navidrome/adapters/deezer" + _ "github.com/navidrome/navidrome/adapters/gotaglib" _ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/listenbrainz" _ "github.com/navidrome/navidrome/adapters/spotify" diff --git a/conf/configuration.go b/conf/configuration.go index 6822a1a9..29e6582e 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -366,10 +366,6 @@ func Load(noConfigDump bool) { disableExternalServices() } - if Server.Scanner.Extractor != consts.DefaultScannerExtractor { - log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor)) - Server.Scanner.Extractor = consts.DefaultScannerExtractor - } logDeprecatedOptions("Scanner.GenreSeparators", "") logDeprecatedOptions("Scanner.GroupAlbumReleases", "") logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored diff --git a/core/library_test.go b/core/library_test.go index 16972da7..175d9c37 100644 --- a/core/library_test.go +++ b/core/library_test.go @@ -9,7 +9,7 @@ import ( "sync" "github.com/deluan/rest" - _ "github.com/navidrome/navidrome/adapters/taglib" // Register taglib extractor + _ "github.com/navidrome/navidrome/adapters/gotaglib" // Register taglib extractor "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" _ "github.com/navidrome/navidrome/core/storage/local" // Register local storage diff --git a/go.mod b/go.mod index eacc4131..8d4ee3f7 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,13 @@ module github.com/navidrome/navidrome go 1.25 -// Fork to fix https://github.com/navidrome/navidrome/issues/3254 -replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d +replace ( + // Fork to fix https://github.com/navidrome/navidrome/issues/3254 + github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d + + // Fork to implement raw tags support + go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260118171208-db06bab917c7 +) require ( github.com/Masterminds/squirrel v1.5.4 @@ -60,6 +65,7 @@ require ( github.com/tetratelabs/wazero v1.11.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 + go.senan.xyz/taglib v0.0.0-00010101000000-000000000000 go.uber.org/goleak v1.3.0 golang.org/x/image v0.35.0 golang.org/x/net v0.49.0 diff --git a/go.sum b/go.sum index 255a1bd0..89aad7a9 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/deluan/go-taglib v0.0.0-20260118171208-db06bab917c7 h1:ICwI2s4BQdDgp+TY2mAf0jMB7B2hgML7IsSAKTuTRBk= +github.com/deluan/go-taglib v0.0.0-20260118171208-db06bab917c7/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=