diff --git a/scanner/metadata/ffmpeg.go b/scanner/metadata/ffmpeg/ffmpeg.go similarity index 83% rename from scanner/metadata/ffmpeg.go rename to scanner/metadata/ffmpeg/ffmpeg.go index 603ac10a5..e7a5a3718 100644 --- a/scanner/metadata/ffmpeg.go +++ b/scanner/metadata/ffmpeg/ffmpeg.go @@ -1,4 +1,4 @@ -package metadata +package ffmpeg import ( "bufio" @@ -13,15 +13,17 @@ import ( "github.com/navidrome/navidrome/log" ) -type ffmpegExtractor struct{} +type Parser struct{} -func (e *ffmpegExtractor) Extract(files ...string) (map[string]*Tags, error) { +type parsedTags = map[string][]string + +func (e *Parser) Parse(files ...string) (map[string]parsedTags, error) { args := e.createProbeCommand(files) log.Trace("Executing command", "args", args) cmd := exec.Command(args[0], args[1:]...) // #nosec output, _ := cmd.CombinedOutput() - fileTags := map[string]*Tags{} + fileTags := map[string]parsedTags{} if len(output) == 0 { return fileTags, errors.New("error extracting metadata files") } @@ -36,6 +38,27 @@ func (e *ffmpegExtractor) Extract(files ...string) (map[string]*Tags, error) { return fileTags, nil } +func (e *Parser) extractMetadata(filePath, info string) (parsedTags, error) { + tags := e.parseInfo(info) + if len(tags) == 0 { + log.Trace("Not a media file. Skipping", "filePath", filePath) + return nil, errors.New("not a media file") + } + + alternativeTags := map[string][]string{ + "disc": {"tpa"}, + "has_picture": {"metadata_block_picture"}, + } + for tagName, alternatives := range alternativeTags { + for _, altName := range alternatives { + if altValue, ok := tags[altName]; ok { + tags[tagName] = append(tags[tagName], altValue...) + } + } + } + return tags, nil +} + var ( // Input #0, mp3, from 'groovin.mp3': inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`) @@ -56,7 +79,7 @@ var ( coverRx = regexp.MustCompile(`^\s{2,4}Stream #\d+:\d+: (Video):.*`) ) -func (e *ffmpegExtractor) parseOutput(output string) map[string]string { +func (e *Parser) parseOutput(output string) map[string]string { outputs := map[string]string{} all := inputRegex.FindAllStringSubmatchIndex(output, -1) for i, loc := range all { @@ -78,21 +101,7 @@ func (e *ffmpegExtractor) parseOutput(output string) map[string]string { return outputs } -func (e *ffmpegExtractor) extractMetadata(filePath, info string) (*Tags, error) { - parsedTags := e.parseInfo(info) - if len(parsedTags) == 0 { - log.Trace("Not a media file. Skipping", "filePath", filePath) - return nil, errors.New("not a media file") - } - - tags := NewTags(filePath, parsedTags, map[string][]string{ - "disc": {"tpa"}, - "has_picture": {"metadata_block_picture"}, - }) - return tags, nil -} - -func (e *ffmpegExtractor) parseInfo(info string) map[string][]string { +func (e *Parser) parseInfo(info string) map[string][]string { tags := map[string][]string{} reader := strings.NewReader(info) @@ -158,7 +167,7 @@ func (e *ffmpegExtractor) parseInfo(info string) map[string][]string { var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC) -func (e *ffmpegExtractor) parseDuration(tag string) string { +func (e *Parser) parseDuration(tag string) string { d, err := time.Parse("15:04:05", tag) if err != nil { return "0" @@ -167,7 +176,7 @@ func (e *ffmpegExtractor) parseDuration(tag string) string { } // Inputs will always be absolute paths -func (e *ffmpegExtractor) createProbeCommand(inputs []string) []string { +func (e *Parser) createProbeCommand(inputs []string) []string { split := strings.Split(conf.Server.ProbeCommand, " ") args := make([]string, 0) diff --git a/scanner/metadata/ffmpeg/ffmpeg_suite_test.go b/scanner/metadata/ffmpeg/ffmpeg_suite_test.go new file mode 100644 index 000000000..239c86f48 --- /dev/null +++ b/scanner/metadata/ffmpeg/ffmpeg_suite_test.go @@ -0,0 +1,17 @@ +package ffmpeg + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestFFMpeg(t *testing.T) { + tests.Init(t, true) + log.SetLevel(log.LevelCritical) + RegisterFailHandler(Fail) + RunSpecs(t, "FFMpeg Suite") +} diff --git a/scanner/metadata/ffmpeg_test.go b/scanner/metadata/ffmpeg/ffmpeg_test.go similarity index 65% rename from scanner/metadata/ffmpeg_test.go rename to scanner/metadata/ffmpeg/ffmpeg_test.go index 203fd5425..c57079fde 100644 --- a/scanner/metadata/ffmpeg_test.go +++ b/scanner/metadata/ffmpeg/ffmpeg_test.go @@ -1,53 +1,14 @@ -package metadata +package ffmpeg import ( . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) -var _ = Describe("ffmpegExtractor", func() { - var e *ffmpegExtractor +var _ = Describe("Parser", func() { + var e *Parser BeforeEach(func() { - e = &ffmpegExtractor{} - }) - // TODO Need to mock `ffmpeg` - XContext("Extract", func() { - It("correctly parses metadata from all files in folder", func() { - mds, err := e.Extract("tests/fixtures/test.mp3", "tests/fixtures/test.ogg") - Expect(err).NotTo(HaveOccurred()) - Expect(mds).To(HaveLen(2)) - - m := mds["tests/fixtures/test.mp3"] - Expect(m.Title()).To(Equal("Song")) - Expect(m.Album()).To(Equal("Album")) - Expect(m.Artist()).To(Equal("Artist")) - Expect(m.AlbumArtist()).To(Equal("Album Artist")) - Expect(m.Compilation()).To(BeTrue()) - Expect(m.Genres()).To(Equal("Rock")) - Expect(m.Year()).To(Equal(2014)) - n, t := m.TrackNumber() - Expect(n).To(Equal(2)) - Expect(t).To(Equal(10)) - n, t = m.DiscNumber() - Expect(n).To(Equal(1)) - Expect(t).To(Equal(2)) - Expect(m.HasPicture()).To(BeTrue()) - Expect(m.Duration()).To(BeNumerically("~", 1.03, 0.001)) - Expect(m.BitRate()).To(Equal(192)) - Expect(m.FilePath()).To(Equal("tests/fixtures/test.mp3")) - Expect(m.Suffix()).To(Equal("mp3")) - Expect(m.Size()).To(Equal(int64(51876))) - - m = mds["tests/fixtures/test.ogg"] - Expect(err).To(BeNil()) - Expect(m.Title()).To(BeEmpty()) - Expect(m.HasPicture()).To(BeFalse()) - Expect(m.Duration()).To(BeNumerically("~", 1.04, 0.001)) - Expect(m.BitRate()).To(Equal(16)) - Expect(m.Suffix()).To(Equal("ogg")) - Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg")) - Expect(m.Size()).To(Equal(int64(5065))) - }) + e = &Parser{} }) Context("extractMetadata", func() { @@ -70,13 +31,13 @@ Input #0, ape, from './Capture/02 01 - Symphony No. 5 in C minor, Op. 67 I. Alle CatalogNumber : PLD 1201 ` md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.CatalogNum()).To(Equal("PLD 1201")) - Expect(md.MbzTrackID()).To(Equal("ffe06940-727a-415a-b608-b7e45737f9d8")) - Expect(md.MbzAlbumID()).To(Equal("71eb5e4a-90e2-4a31-a2d1-a96485fcb667")) - Expect(md.MbzArtistID()).To(Equal("1f9df192-a621-4f54-8850-2c5373b7eac9")) - Expect(md.MbzAlbumArtistID()).To(Equal("89ad4ac3-39f7-470e-963a-56509c546377")) - Expect(md.MbzAlbumType()).To(Equal("album")) - Expect(md.MbzAlbumComment()).To(Equal("MP3")) + Expect(md).To(HaveKeyWithValue("catalognumber", []string{"PLD 1201"})) + Expect(md).To(HaveKeyWithValue("musicbrainz_trackid", []string{"ffe06940-727a-415a-b608-b7e45737f9d8"})) + Expect(md).To(HaveKeyWithValue("musicbrainz_albumid", []string{"71eb5e4a-90e2-4a31-a2d1-a96485fcb667"})) + Expect(md).To(HaveKeyWithValue("musicbrainz_artistid", []string{"1f9df192-a621-4f54-8850-2c5373b7eac9"})) + Expect(md).To(HaveKeyWithValue("musicbrainz_albumartistid", []string{"89ad4ac3-39f7-470e-963a-56509c546377"})) + Expect(md).To(HaveKeyWithValue("musicbrainz_albumtype", []string{"album"})) + Expect(md).To(HaveKeyWithValue("musicbrainz_albumcomment", []string{"MP3"})) }) It("detects embedded cover art correctly", func() { @@ -88,7 +49,7 @@ Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/ Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc` md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.HasPicture()).To(BeTrue()) + Expect(md).To(HaveKeyWithValue("has_picture", []string{"true"})) }) It("detects embedded cover art in ffmpeg 4.4 output", func() { @@ -103,7 +64,7 @@ Input #0, flac, from '/run/media/naomi/Archivio/Musica/Katy Perry/Chained to the Metadata: comment : Cover (front)` md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.HasPicture()).To(BeTrue()) + Expect(md).To(HaveKeyWithValue("has_picture", []string{"true"})) }) It("detects embedded cover art in ogg containers", func() { @@ -116,7 +77,7 @@ Input #0, ogg, from '/Users/deluan/Music/iTunes/iTunes Media/Music/_Testes/Jamai metadata_block_picture: AAAAAwAAAAppbWFnZS9qcGVnAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4Id/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQ TITLE : Jamaican In New York (Album Version)` md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.HasPicture()).To(BeTrue()) + Expect(md).To(HaveKey("has_picture")) }) It("gets bitrate from the stream, if available", func() { @@ -125,17 +86,7 @@ Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/ Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s` md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.BitRate()).To(Equal(192)) - }) - - It("parses correctly the compilation tag", func() { - const output = ` -Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': - Metadata: - compilation : 1 - Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.Compilation()).To(BeTrue()) + Expect(md).To(HaveKeyWithValue("bitrate", []string{"192"})) }) It("parses duration with milliseconds", func() { @@ -143,7 +94,7 @@ Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/ Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s` md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.Duration()).To(BeNumerically("~", 302.63, 0.001)) + Expect(md).To(HaveKeyWithValue("duration", []string{"302.63"})) }) It("parses stream level tags", func() { @@ -156,7 +107,7 @@ Input #0, ogg, from './01-02 Drive (Teku).opus': Metadata: TITLE : Drive (Teku)` md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.Title()).To(Equal("Drive (Teku)")) + Expect(md).To(HaveKeyWithValue("title", []string{"Drive (Teku)"})) }) It("does not overlap top level tags with the stream level tags", func() { @@ -168,33 +119,7 @@ Input #0, mp3, from 'groovin.mp3': Metadata: title : garbage` md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.Title()).To(Equal("Groovin' (feat. Daniel Sneijers, Susanne Alt)")) - }) - - It("ignores case in the tag name", func() { - const output = ` -Input #0, flac, from '/Users/deluan/Downloads/06. Back In Black.flac': - Metadata: - ALBUM : Back In Black - DATE : 1980.07.25 - disc : 1 - GENRE : Hard Rock - TITLE : Back In Black - DISCTOTAL : 1 - TRACKTOTAL : 10 - track : 6 - Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s` - md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.Title()).To(Equal("Back In Black")) - Expect(md.Album()).To(Equal("Back In Black")) - Expect(md.Genres()).To(ConsistOf("Hard Rock")) - n, t := md.TrackNumber() - Expect(n).To(Equal(6)) - Expect(t).To(Equal(10)) - n, t = md.DiscNumber() - Expect(n).To(Equal(1)) - Expect(t).To(Equal(1)) - Expect(md.Year()).To(Equal(1980)) + Expect(md).To(HaveKeyWithValue("title", []string{"Groovin' (feat. Daniel Sneijers, Susanne Alt)", "garbage"})) }) It("parses multiline tags", func() { @@ -227,7 +152,7 @@ Tracklist: 07. Wunderbar 08. Quarta Dimensão` md, _ := e.extractMetadata("tests/fixtures/test.mp3", outputWithMultilineComment) - Expect(md.Comment()).To(Equal(expectedComment)) + Expect(md).To(HaveKeyWithValue("comment", []string{expectedComment})) }) It("parses sort tags correctly", func() { @@ -244,14 +169,14 @@ Input #0, mp3, from '/Users/deluan/Downloads/椎名林檎 - 加爾基 精液 栗 ALBUMARTISTSORT : Shiina, Ringo ` md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.Title()).To(Equal("ドツペルゲンガー")) - Expect(md.Album()).To(Equal("加爾基 精液 栗ノ花")) - Expect(md.Artist()).To(Equal("椎名林檎")) - Expect(md.AlbumArtist()).To(Equal("椎名林檎")) - Expect(md.SortTitle()).To(Equal("Dopperugengā")) - Expect(md.SortAlbum()).To(Equal("Kalk Samen Kuri No Hana")) - Expect(md.SortArtist()).To(Equal("Shiina, Ringo")) - Expect(md.SortAlbumArtist()).To(Equal("Shiina, Ringo")) + Expect(md).To(HaveKeyWithValue("title", []string{"ドツペルゲンガー"})) + Expect(md).To(HaveKeyWithValue("album", []string{"加爾基 精液 栗ノ花"})) + Expect(md).To(HaveKeyWithValue("artist", []string{"椎名林檎"})) + Expect(md).To(HaveKeyWithValue("album_artist", []string{"椎名林檎"})) + Expect(md).To(HaveKeyWithValue("title-sort", []string{"Dopperugengā"})) + Expect(md).To(HaveKeyWithValue("albumsort", []string{"Kalk Samen Kuri No Hana"})) + Expect(md).To(HaveKeyWithValue("artist_sort", []string{"Shiina, Ringo"})) + Expect(md).To(HaveKeyWithValue("albumartistsort", []string{"Shiina, Ringo"})) }) It("ignores cover comment", func() { @@ -266,7 +191,7 @@ Input #0, mp3, from './Edie Brickell/Picture Perfect Morning/01-01 Tomorrow Come Metadata: comment : Cover (front)` md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.Comment()).To(Equal("")) + Expect(md).ToNot(HaveKey("comment")) }) It("parses tags with spaces in the name", func() { @@ -276,7 +201,7 @@ Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hu ALBUM ARTIST : Wyclef Jean ` md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.AlbumArtist()).To(Equal("Wyclef Jean")) + Expect(md).To(HaveKeyWithValue("album artist", []string{"Wyclef Jean"})) }) }) @@ -291,7 +216,7 @@ Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hu Metadata: TBPM : 123` md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) - Expect(md.Bpm()).To(Equal(123)) + Expect(md).To(HaveKeyWithValue("tbpm", []string{"123"})) }) It("parses and rounds a floating point fBPM tag", func() { @@ -300,6 +225,6 @@ Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hu Metadata: FBPM : 141.7` md, _ := e.extractMetadata("tests/fixtures/test.ogg", output) - Expect(md.Bpm()).To(Equal(142)) + Expect(md).To(HaveKeyWithValue("fbpm", []string{"141.7"})) }) }) diff --git a/scanner/metadata/metadata.go b/scanner/metadata/metadata.go index 06a7a237b..3df64b100 100644 --- a/scanner/metadata/metadata.go +++ b/scanner/metadata/metadata.go @@ -10,53 +10,59 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/scanner/metadata/ffmpeg" + + "github.com/navidrome/navidrome/scanner/metadata/taglib" + "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" ) -type Extractor interface { - Extract(files ...string) (map[string]*Tags, error) +type Parser interface { + Parse(files ...string) (map[string]map[string][]string, error) } func Extract(files ...string) (map[string]*Tags, error) { - var e Extractor + var e Parser switch conf.Server.Scanner.Extractor { case "taglib": - e = &taglibExtractor{} + e = &taglib.Parser{} case "ffmpeg": - e = &ffmpegExtractor{} + e = &ffmpeg.Parser{} default: - log.Warn("Invalid Scanner.Extractor option. Using default taglib", "requested", conf.Server.Scanner.Extractor, + log.Warn("Invalid 'Scanner.Extractor' option. Using default 'taglib'", "requested", conf.Server.Scanner.Extractor, "validOptions", "ffmpeg,taglib") - e = &taglibExtractor{} + e = &taglib.Parser{} } - return e.Extract(files...) + extractedTags, err := e.Parse(files...) + if err != nil { + return nil, err + } + + result := map[string]*Tags{} + for filePath, tags := range extractedTags { + fileInfo, err := os.Stat(filePath) + if err != nil { + log.Warn("Error stating file. Skipping", "filePath", filePath, err) + continue + } + + result[filePath] = &Tags{ + filePath: filePath, + fileInfo: fileInfo, + tags: tags, + } + } + + return result, nil } type Tags struct { filePath string - suffix string fileInfo os.FileInfo tags map[string][]string - custom map[string][]string -} - -func NewTags(filePath string, tags, custom map[string][]string) *Tags { - fileInfo, err := os.Stat(filePath) - if err != nil { - log.Warn("Error stating file. Skipping", "filePath", filePath, err) - return nil - } - - return &Tags{ - filePath: filePath, - suffix: strings.ToLower(strings.TrimPrefix(path.Ext(filePath), ".")), - fileInfo: fileInfo, - tags: tags, - custom: custom, - } } // Common tags @@ -109,11 +115,10 @@ func (t *Tags) BitRate() int { return t.getInt("bitrate") } func (t *Tags) ModificationTime() time.Time { return t.fileInfo.ModTime() } func (t *Tags) Size() int64 { return t.fileInfo.Size() } func (t *Tags) FilePath() string { return t.filePath } -func (t *Tags) Suffix() string { return t.suffix } +func (t *Tags) Suffix() string { return strings.ToLower(strings.TrimPrefix(path.Ext(t.filePath), ".")) } func (t *Tags) getTags(tagNames ...string) []string { - allTags := append(tagNames, t.custom[tagNames[0]]...) - for _, tag := range allTags { + for _, tag := range tagNames { if v, ok := t.tags[tag]; ok { return v } @@ -130,7 +135,6 @@ func (t *Tags) getFirstTagValue(tagNames ...string) string { } func (t *Tags) getAllTagValues(tagNames ...string) []string { - tagNames = append(tagNames, t.custom[tagNames[0]]...) var values []string for _, tag := range tagNames { if v, ok := t.tags[tag]; ok { diff --git a/scanner/metadata/metadata_suite_test.go b/scanner/metadata/metadata_suite_test.go index c9c201dc9..25ed29dc1 100644 --- a/scanner/metadata/metadata_suite_test.go +++ b/scanner/metadata/metadata_suite_test.go @@ -9,7 +9,7 @@ import ( . "github.com/onsi/gomega" ) -func TestScanner(t *testing.T) { +func TestMetadata(t *testing.T) { tests.Init(t, true) log.SetLevel(log.LevelCritical) RegisterFailHandler(Fail) diff --git a/scanner/metadata/metadata_test.go b/scanner/metadata/metadata_test.go index 9f98fa120..7057297ef 100644 --- a/scanner/metadata/metadata_test.go +++ b/scanner/metadata/metadata_test.go @@ -1,11 +1,55 @@ package metadata import ( + "github.com/navidrome/navidrome/conf" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) var _ = Describe("Tags", func() { + Context("Extract", func() { + BeforeEach(func() { + conf.Server.Scanner.Extractor = "taglib" + }) + + It("correctly parses metadata from all files in folder", func() { + mds, err := Extract("tests/fixtures/test.mp3", "tests/fixtures/test.ogg") + Expect(err).NotTo(HaveOccurred()) + Expect(mds).To(HaveLen(2)) + + m := mds["tests/fixtures/test.mp3"] + Expect(m.Title()).To(Equal("Song")) + Expect(m.Album()).To(Equal("Album")) + Expect(m.Artist()).To(Equal("Artist")) + Expect(m.AlbumArtist()).To(Equal("Album Artist")) + Expect(m.Compilation()).To(BeTrue()) + Expect(m.Genres()).To(Equal([]string{"Rock"})) + Expect(m.Year()).To(Equal(2014)) + n, t := m.TrackNumber() + Expect(n).To(Equal(2)) + Expect(t).To(Equal(10)) + n, t = m.DiscNumber() + Expect(n).To(Equal(1)) + Expect(t).To(Equal(2)) + Expect(m.HasPicture()).To(BeTrue()) + Expect(m.Duration()).To(BeNumerically("~", 1, 0.01)) + Expect(m.BitRate()).To(Equal(192)) + Expect(m.FilePath()).To(Equal("tests/fixtures/test.mp3")) + Expect(m.Suffix()).To(Equal("mp3")) + Expect(m.Size()).To(Equal(int64(51876))) + + m = mds["tests/fixtures/test.ogg"] + Expect(err).To(BeNil()) + Expect(m.Title()).To(BeEmpty()) + Expect(m.HasPicture()).To(BeFalse()) + Expect(m.Duration()).To(BeNumerically("~", 1.00, 0.01)) + Expect(m.BitRate()).To(Equal(18)) + Expect(m.Suffix()).To(Equal("ogg")) + Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg")) + Expect(m.Size()).To(Equal(int64(5065))) + }) + }) + Describe("getYear", func() { It("parses the year correctly", func() { var examples = map[string]int{ @@ -65,12 +109,23 @@ var _ = Describe("Tags", func() { It("returns values from all tag names", func() { md := &Tags{} md.tags = map[string][]string{ - "genre": {"Rock", "Pop"}, - "_genre": {"New Wave"}, + "genre": {"Rock", "Pop", "New Wave"}, } - md.custom = map[string][]string{"genre": {"_genre"}} Expect(md.Genres()).To(ConsistOf("Rock", "Pop", "New Wave")) }) }) + + Describe("Bpm", func() { + var t *Tags + BeforeEach(func() { + t = &Tags{tags: map[string][]string{ + "fbpm": []string{"141.7"}, + }} + }) + + It("rounds a floating point fBPM tag", func() { + Expect(t.Bpm()).To(Equal(142)) + }) + }) }) diff --git a/scanner/metadata/taglib.go b/scanner/metadata/taglib.go deleted file mode 100644 index 796ff2efb..000000000 --- a/scanner/metadata/taglib.go +++ /dev/null @@ -1,36 +0,0 @@ -package metadata - -import ( - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/scanner/metadata/taglib" -) - -type taglibExtractor struct{} - -func (e *taglibExtractor) Extract(paths ...string) (map[string]*Tags, error) { - fileTags := map[string]*Tags{} - for _, path := range paths { - tags, err := e.extractMetadata(path) - if err == nil { - fileTags[path] = tags - } - } - return fileTags, nil -} - -func (e *taglibExtractor) extractMetadata(filePath string) (*Tags, error) { - parsedTags, err := taglib.Read(filePath) - if err != nil { - log.Warn("Error reading metadata from file. Skipping", "filePath", filePath, err) - } - - tags := NewTags(filePath, parsedTags, map[string][]string{ - "title": {"_track", "titlesort"}, - "album": {"_album", "albumsort"}, - "artist": {"_artist", "artistsort"}, - "date": {"_year"}, - "track": {"_track"}, - }) - - return tags, nil -} diff --git a/scanner/metadata/taglib/taglib.go b/scanner/metadata/taglib/taglib.go new file mode 100644 index 000000000..e69fb34b9 --- /dev/null +++ b/scanner/metadata/taglib/taglib.go @@ -0,0 +1,43 @@ +package taglib + +import ( + "github.com/navidrome/navidrome/log" +) + +type Parser struct{} + +type parsedTags = map[string][]string + +func (e *Parser) Parse(paths ...string) (map[string]parsedTags, error) { + fileTags := map[string]parsedTags{} + for _, path := range paths { + tags, err := e.extractMetadata(path) + if err == nil { + fileTags[path] = tags + } + } + return fileTags, nil +} + +func (e *Parser) extractMetadata(filePath string) (parsedTags, error) { + tags, err := Read(filePath) + if err != nil { + log.Warn("Error reading metadata from file. Skipping", "filePath", filePath, err) + } + + alternativeTags := map[string][]string{ + "title": {"titlesort"}, + "album": {"albumsort"}, + "artist": {"artistsort"}, + "tracknumber": {"trck", "_track"}, + } + + for tagName, alternatives := range alternativeTags { + for _, altName := range alternatives { + if altValue, ok := tags[altName]; ok { + tags[tagName] = append(tags[tagName], altValue...) + } + } + } + return tags, nil +} diff --git a/scanner/metadata/taglib/taglib_suite_test.go b/scanner/metadata/taglib/taglib_suite_test.go new file mode 100644 index 000000000..c825817fa --- /dev/null +++ b/scanner/metadata/taglib/taglib_suite_test.go @@ -0,0 +1,17 @@ +package taglib + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestTagLib(t *testing.T) { + tests.Init(t, true) + log.SetLevel(log.LevelCritical) + RegisterFailHandler(Fail) + RunSpecs(t, "TagLib Suite") +} diff --git a/scanner/metadata/taglib/taglib_test.go b/scanner/metadata/taglib/taglib_test.go new file mode 100644 index 000000000..9f237916f --- /dev/null +++ b/scanner/metadata/taglib/taglib_test.go @@ -0,0 +1,49 @@ +package taglib + +import ( + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Parser", func() { + var e *Parser + BeforeEach(func() { + e = &Parser{} + }) + Context("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)) + + m := mds["tests/fixtures/test.mp3"] + Expect(m).To(HaveKeyWithValue("title", []string{"Song", "Song"})) + 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("tcmp", []string{"1"})) // Compilation + Expect(m).To(HaveKeyWithValue("genre", []string{"Rock"})) + Expect(m).To(HaveKeyWithValue("date", []string{"2014", "2014"})) + Expect(m).To(HaveKeyWithValue("tracknumber", []string{"2/10", "2/10", "2"})) + Expect(m).To(HaveKeyWithValue("discnumber", []string{"1/2"})) + Expect(m).To(HaveKeyWithValue("has_picture", []string{"true"})) + Expect(m).To(HaveKeyWithValue("duration", []string{"1"})) + Expect(m).To(HaveKeyWithValue("bitrate", []string{"192"})) + Expect(m).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"})) + Expect(m).To(HaveKeyWithValue("lyrics", []string{"Lyrics 1\rLyrics 2"})) + Expect(m).To(HaveKeyWithValue("bpm", []string{"123"})) + + m = mds["tests/fixtures/test.ogg"] + Expect(err).To(BeNil()) + Expect(m).ToNot(HaveKey("title")) + Expect(m).ToNot(HaveKey("has_picture")) + Expect(m).To(HaveKeyWithValue("duration", []string{"1"})) + Expect(m).To(HaveKeyWithValue("fbpm", []string{"141.7"})) + + // TabLib 1.12 returns 18, previous versions return 39. + // See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b + Expect(m).To(HaveKey("bitrate")) + Expect(m["bitrate"][0]).To(BeElementOf("18", "39")) + }) + }) +}) diff --git a/scanner/metadata/taglib/taglib_parser.cpp b/scanner/metadata/taglib/taglib_wrapper.cpp similarity index 93% rename from scanner/metadata/taglib/taglib_parser.cpp rename to scanner/metadata/taglib/taglib_wrapper.cpp index e0252eeaf..353587c1d 100644 --- a/scanner/metadata/taglib/taglib_parser.cpp +++ b/scanner/metadata/taglib/taglib_wrapper.cpp @@ -13,7 +13,7 @@ #include #include -#include "taglib_parser.h" +#include "taglib_wrapper.h" char has_cover(const TagLib::FileRef f); @@ -39,16 +39,16 @@ int taglib_read(const char *filename, unsigned long id) { TagLib::Tag *basic = f.file()->tag(); if (!basic->isEmpty()) { if (!basic->title().isEmpty()) { - tags.insert("_title", basic->title()); + tags.insert("title", basic->title()); } if (!basic->artist().isEmpty()) { - tags.insert("_artist", basic->artist()); + tags.insert("artist", basic->artist()); } if (!basic->album().isEmpty()) { - tags.insert("_album", basic->album()); + tags.insert("album", basic->album()); } if (basic->year() > 0) { - tags.insert("_year", TagLib::String::number(basic->year())); + tags.insert("date", TagLib::String::number(basic->year())); } if (basic->track() > 0) { tags.insert("_track", TagLib::String::number(basic->track())); diff --git a/scanner/metadata/taglib/taglib_parser.go b/scanner/metadata/taglib/taglib_wrapper.go similarity index 98% rename from scanner/metadata/taglib/taglib_parser.go rename to scanner/metadata/taglib/taglib_wrapper.go index 27b3a129e..900a0af9b 100644 --- a/scanner/metadata/taglib/taglib_parser.go +++ b/scanner/metadata/taglib/taglib_wrapper.go @@ -7,7 +7,7 @@ package taglib #include #include #include -#include "taglib_parser.h" +#include "taglib_wrapper.h" */ import "C" import ( diff --git a/scanner/metadata/taglib/taglib_parser.h b/scanner/metadata/taglib/taglib_wrapper.h similarity index 100% rename from scanner/metadata/taglib/taglib_parser.h rename to scanner/metadata/taglib/taglib_wrapper.h diff --git a/scanner/metadata/taglib_test.go b/scanner/metadata/taglib_test.go deleted file mode 100644 index f949c89a7..000000000 --- a/scanner/metadata/taglib_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package metadata - -import ( - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" -) - -var _ = Describe("taglibExtractor", func() { - Context("Extract", func() { - It("correctly parses metadata from all files in folder", func() { - e := &taglibExtractor{} - mds, err := e.Extract("tests/fixtures/test.mp3", "tests/fixtures/test.ogg") - Expect(err).NotTo(HaveOccurred()) - Expect(mds).To(HaveLen(2)) - - m := mds["tests/fixtures/test.mp3"] - Expect(m.Title()).To(Equal("Song")) - Expect(m.Album()).To(Equal("Album")) - Expect(m.Artist()).To(Equal("Artist")) - Expect(m.AlbumArtist()).To(Equal("Album Artist")) - Expect(m.Compilation()).To(BeTrue()) - Expect(m.Genres()).To(ConsistOf("Rock")) - Expect(m.Year()).To(Equal(2014)) - n, t := m.TrackNumber() - Expect(n).To(Equal(2)) - Expect(t).To(Equal(10)) - n, t = m.DiscNumber() - Expect(n).To(Equal(1)) - Expect(t).To(Equal(2)) - Expect(m.HasPicture()).To(BeTrue()) - Expect(m.Duration()).To(Equal(float32(1))) - Expect(m.BitRate()).To(Equal(192)) - Expect(m.FilePath()).To(Equal("tests/fixtures/test.mp3")) - Expect(m.Suffix()).To(Equal("mp3")) - Expect(m.Size()).To(Equal(int64(51876))) - Expect(m.Comment()).To(Equal("Comment1\nComment2")) - Expect(m.Bpm()).To(Equal(123)) - - m = mds["tests/fixtures/test.ogg"] - Expect(err).To(BeNil()) - Expect(m.Title()).To(BeEmpty()) - Expect(m.HasPicture()).To(BeFalse()) - Expect(m.Duration()).To(Equal(float32(1))) - Expect(m.Suffix()).To(Equal("ogg")) - Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg")) - Expect(m.Size()).To(Equal(int64(5065))) - Expect(m.Bpm()).To(Equal(142)) // This file has a floating point BPM set to 141.7 under the fBPM tag. Ensure we parse and round correctly. - - // TabLib 1.12 returns 18, previous versions return 39. - // See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b - Expect(m.BitRate()).To(BeElementOf(18, 39)) - }) - }) -})