diff --git a/go.mod b/go.mod index b7ea6d5be..94e688b6f 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,9 @@ require ( github.com/mattn/go-sqlite3 v2.0.3+incompatible github.com/microcosm-cc/bluemonday v1.0.4 github.com/mitchellh/mapstructure v1.3.2 // indirect + github.com/nicksellen/audiotags v0.0.0-20160226222119-94015fa599bd + github.com/onsi/ginkgo v1.14.0 + github.com/onsi/gomega v1.10.1 github.com/onsi/ginkgo v1.14.1 github.com/onsi/gomega v1.10.2 github.com/pelletier/go-toml v1.8.0 // indirect diff --git a/go.sum b/go.sum index dc295d21d..8c4298faa 100644 --- a/go.sum +++ b/go.sum @@ -253,6 +253,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nicksellen/audiotags v0.0.0-20160226222119-94015fa599bd h1:xKn/gU8lZupoZt/HE7a/R3aH93iUO6JwyRsYelQUsRI= +github.com/nicksellen/audiotags v0.0.0-20160226222119-94015fa599bd/go.mod h1:B6icauz2l4tkYQxmDtCH4qmNWz/evSW5CsOqp6IE5IE= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= diff --git a/scanner/metadata/ffmpeg.go b/scanner/metadata/ffmpeg.go index 426231e08..8a4f76f86 100644 --- a/scanner/metadata/ffmpeg.go +++ b/scanner/metadata/ffmpeg.go @@ -3,59 +3,29 @@ package metadata import ( "bufio" "errors" - "fmt" "os" "os/exec" - "path" "regexp" - "strconv" "strings" - "time" "github.com/deluan/navidrome/conf" "github.com/deluan/navidrome/log" ) type ffmpegMetadata struct { - filePath string - suffix string - fileInfo os.FileInfo - tags map[string]string + baseMetadata } -func (m *ffmpegMetadata) Title() string { return m.getTag("title", "sort_name") } -func (m *ffmpegMetadata) Album() string { return m.getTag("album", "sort_album") } -func (m *ffmpegMetadata) Artist() string { return m.getTag("artist", "sort_artist") } -func (m *ffmpegMetadata) AlbumArtist() string { return m.getTag("album_artist", "albumartist") } -func (m *ffmpegMetadata) SortTitle() string { return m.getSortTag("", "title", "name") } -func (m *ffmpegMetadata) SortAlbum() string { return m.getSortTag("", "album") } -func (m *ffmpegMetadata) SortArtist() string { return m.getSortTag("", "artist") } -func (m *ffmpegMetadata) SortAlbumArtist() string { - return m.getSortTag("tso2", "albumartist", "album_artist") -} -func (m *ffmpegMetadata) Composer() string { return m.getTag("composer", "tcm", "sort_composer") } -func (m *ffmpegMetadata) Genre() string { return m.getTag("genre") } -func (m *ffmpegMetadata) Year() int { return m.parseYear("date") } -func (m *ffmpegMetadata) TrackNumber() (int, int) { return m.parseTuple("track") } -func (m *ffmpegMetadata) DiscNumber() (int, int) { return m.parseTuple("tpa", "disc") } -func (m *ffmpegMetadata) DiscSubtitle() string { - return m.getTag("tsst", "discsubtitle", "setsubtitle") -} +func (m *ffmpegMetadata) Duration() float32 { return m.parseDuration("duration") } +func (m *ffmpegMetadata) BitRate() int { return m.parseInt("bitrate") } func (m *ffmpegMetadata) HasPicture() bool { return m.getTag("has_picture", "metadata_block_picture") != "" } -func (m *ffmpegMetadata) Comment() string { return m.getTag("comment") } -func (m *ffmpegMetadata) Compilation() bool { return m.parseBool("compilation") } -func (m *ffmpegMetadata) Duration() float32 { return m.parseDuration("duration") } -func (m *ffmpegMetadata) BitRate() int { return m.parseInt("bitrate") } -func (m *ffmpegMetadata) ModificationTime() time.Time { return m.fileInfo.ModTime() } -func (m *ffmpegMetadata) FilePath() string { return m.filePath } -func (m *ffmpegMetadata) Suffix() string { return m.suffix } -func (m *ffmpegMetadata) Size() int64 { return m.fileInfo.Size() } +func (m *ffmpegMetadata) DiscNumber() (int, int) { return m.parseTuple("tpa", "disc") } -type ffmpegMetadataExtractor struct{} +type ffmpegExtractor struct{} -func (e *ffmpegMetadataExtractor) Extract(files ...string) (map[string]Metadata, error) { +func (e *ffmpegExtractor) Extract(files ...string) (map[string]Metadata, error) { args := createProbeCommand(files) log.Trace("Executing command", "args", args) @@ -116,8 +86,9 @@ func parseOutput(output string) map[string]string { } func extractMetadata(filePath, info string) (*ffmpegMetadata, error) { - m := &ffmpegMetadata{filePath: filePath, tags: map[string]string{}} - m.suffix = strings.ToLower(strings.TrimPrefix(path.Ext(filePath), ".")) + m := &ffmpegMetadata{} + m.filePath = filePath + m.tags = map[string]string{} var err error m.fileInfo, err = os.Stat(filePath) if err != nil { @@ -175,88 +146,6 @@ func (m *ffmpegMetadata) parseInfo(info string) { } } -func (m *ffmpegMetadata) parseInt(tagName string) int { - if v, ok := m.tags[tagName]; ok { - i, _ := strconv.Atoi(v) - return i - } - return 0 -} - -var dateRegex = regexp.MustCompile(`^([12]\d\d\d)`) - -func (m *ffmpegMetadata) parseYear(tagName string) int { - if v, ok := m.tags[tagName]; ok { - match := dateRegex.FindStringSubmatch(v) - if len(match) == 0 { - log.Warn("Error parsing year from ffmpeg date field", "file", m.filePath, "date", v) - return 0 - } - year, _ := strconv.Atoi(match[1]) - return year - } - return 0 -} - -func (m *ffmpegMetadata) getTag(tags ...string) string { - for _, t := range tags { - if v, ok := m.tags[t]; ok { - return v - } - } - return "" -} - -func (m *ffmpegMetadata) getSortTag(originalTag string, tags ...string) string { - formats := []string{"sort%s", "sort_%s", "sort-%s", "%ssort", "%s_sort", "%s-sort"} - all := []string{originalTag} - for _, tag := range tags { - for _, format := range formats { - name := fmt.Sprintf(format, tag) - all = append(all, name) - } - } - return m.getTag(all...) -} - -func (m *ffmpegMetadata) parseTuple(tags ...string) (int, int) { - for _, tagName := range tags { - if v, ok := m.tags[tagName]; ok { - tuple := strings.Split(v, "/") - t1, t2 := 0, 0 - t1, _ = strconv.Atoi(tuple[0]) - if len(tuple) > 1 { - t2, _ = strconv.Atoi(tuple[1]) - } else { - t2, _ = strconv.Atoi(m.tags[tagName+"total"]) - } - return t1, t2 - } - } - return 0, 0 -} - -func (m *ffmpegMetadata) parseBool(tagName string) bool { - if v, ok := m.tags[tagName]; ok { - i, _ := strconv.Atoi(strings.TrimSpace(v)) - return i == 1 - } - return false -} - -var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC) - -func (m *ffmpegMetadata) parseDuration(tagName string) float32 { - if v, ok := m.tags[tagName]; ok { - d, err := time.Parse("15:04:05", v) - if err != nil { - return 0 - } - return float32(d.Sub(zeroTime).Seconds()) - } - return 0 -} - // Inputs will always be absolute paths func createProbeCommand(inputs []string) []string { split := strings.Split(conf.Server.ProbeCommand, " ") diff --git a/scanner/metadata/ffmpeg_test.go b/scanner/metadata/ffmpeg_test.go index a11f16791..62451d1d4 100644 --- a/scanner/metadata/ffmpeg_test.go +++ b/scanner/metadata/ffmpeg_test.go @@ -9,7 +9,7 @@ var _ = Describe("ffmpegMetadata", func() { // TODO Need to mock `ffmpeg` XContext("ExtractAllMetadata", func() { It("correctly parses metadata from all files in folder", func() { - e := &ffmpegMetadataExtractor{} + e := &ffmpegExtractor{} mds, err := e.Extract("tests/fixtures/test.mp3", "tests/fixtures/test.ogg") Expect(err).NotTo(HaveOccurred()) Expect(mds).To(HaveLen(2)) @@ -224,13 +224,15 @@ Input #0, mp3, from '/Users/deluan/Downloads/椎名林檎 - 加爾基 精液 栗 "May 12, 2016": 0, } for tag, expected := range examples { - md := &ffmpegMetadata{tags: map[string]string{"date": tag}} + md := &ffmpegMetadata{} + md.tags = map[string]string{"date": tag} Expect(md.Year()).To(Equal(expected)) } }) It("returns 0 if year is invalid", func() { - md := &ffmpegMetadata{tags: map[string]string{"date": "invalid"}} + md := &ffmpegMetadata{} + md.tags = map[string]string{"date": "invalid"} Expect(md.Year()).To(Equal(0)) }) }) diff --git a/scanner/metadata/metadata.go b/scanner/metadata/metadata.go index 00139db0a..67ec9a424 100644 --- a/scanner/metadata/metadata.go +++ b/scanner/metadata/metadata.go @@ -1,6 +1,25 @@ package metadata -import "time" +import ( + "fmt" + "os" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/deluan/navidrome/log" +) + +type Extractor interface { + Extract(files ...string) (map[string]Metadata, error) +} + +func Extract(files ...string) (map[string]Metadata, error) { + e := &taglibExtractor{} + return e.Extract(files...) +} type Metadata interface { Title() string @@ -28,11 +47,122 @@ type Metadata interface { Size() int64 } -type Extractor interface { - Extract(files ...string) (map[string]Metadata, error) +type baseMetadata struct { + filePath string + fileInfo os.FileInfo + tags map[string]string } -func Extract(files ...string) (map[string]Metadata, error) { - e := &ffmpegMetadataExtractor{} - return e.Extract(files...) +func (m *baseMetadata) Title() string { return m.getTag("title", "sort_name") } +func (m *baseMetadata) Album() string { return m.getTag("album", "sort_album") } +func (m *baseMetadata) Artist() string { return m.getTag("artist", "sort_artist") } +func (m *baseMetadata) AlbumArtist() string { return m.getTag("album_artist", "albumartist") } +func (m *baseMetadata) SortTitle() string { return m.getSortTag("", "title", "name") } +func (m *baseMetadata) SortAlbum() string { return m.getSortTag("", "album") } +func (m *baseMetadata) SortArtist() string { return m.getSortTag("", "artist") } +func (m *baseMetadata) SortAlbumArtist() string { + return m.getSortTag("tso2", "albumartist", "album_artist") +} +func (m *baseMetadata) Composer() string { return m.getTag("composer", "tcm", "sort_composer") } +func (m *baseMetadata) Genre() string { return m.getTag("genre") } +func (m *baseMetadata) Year() int { return m.parseYear("date") } +func (m *baseMetadata) Comment() string { return m.getTag("comment") } +func (m *baseMetadata) Compilation() bool { return m.parseBool("compilation") } +func (m *baseMetadata) TrackNumber() (int, int) { return m.parseTuple("track", "tracknumber") } +func (m *baseMetadata) DiscNumber() (int, int) { return m.parseTuple("disc", "discnumber") } +func (m *baseMetadata) DiscSubtitle() string { + return m.getTag("tsst", "discsubtitle", "setsubtitle") +} + +func (m *baseMetadata) ModificationTime() time.Time { return m.fileInfo.ModTime() } +func (m *baseMetadata) Size() int64 { return m.fileInfo.Size() } +func (m *baseMetadata) FilePath() string { return m.filePath } +func (m *baseMetadata) Suffix() string { + return strings.ToLower(strings.TrimPrefix(path.Ext(m.FilePath()), ".")) +} + +func (m *baseMetadata) Duration() float32 { panic("not implemented") } +func (m *baseMetadata) BitRate() int { panic("not implemented") } +func (m *baseMetadata) HasPicture() bool { panic("not implemented") } + +func (m *baseMetadata) parseInt(tagName string) int { + if v, ok := m.tags[tagName]; ok { + i, _ := strconv.Atoi(v) + return i + } + return 0 +} + +var dateRegex = regexp.MustCompile(`^([12]\d\d\d)`) + +func (m *baseMetadata) parseYear(tagName string) int { + if v, ok := m.tags[tagName]; ok { + match := dateRegex.FindStringSubmatch(v) + if len(match) == 0 { + log.Warn("Error parsing year from ffmpeg date field", "file", m.filePath, "date", v) + return 0 + } + year, _ := strconv.Atoi(match[1]) + return year + } + return 0 +} + +func (m *baseMetadata) getTag(tags ...string) string { + for _, t := range tags { + if v, ok := m.tags[t]; ok { + return v + } + } + return "" +} + +func (m *baseMetadata) getSortTag(originalTag string, tags ...string) string { + formats := []string{"sort%s", "sort_%s", "sort-%s", "%ssort", "%s_sort", "%s-sort"} + all := []string{originalTag} + for _, tag := range tags { + for _, format := range formats { + name := fmt.Sprintf(format, tag) + all = append(all, name) + } + } + return m.getTag(all...) +} + +func (m *baseMetadata) parseTuple(tags ...string) (int, int) { + for _, tagName := range tags { + if v, ok := m.tags[tagName]; ok { + tuple := strings.Split(v, "/") + t1, t2 := 0, 0 + t1, _ = strconv.Atoi(tuple[0]) + if len(tuple) > 1 { + t2, _ = strconv.Atoi(tuple[1]) + } else { + t2, _ = strconv.Atoi(m.tags[tagName+"total"]) + } + return t1, t2 + } + } + return 0, 0 +} + +func (m *baseMetadata) parseBool(tagName string) bool { + if v, ok := m.tags[tagName]; ok { + i, _ := strconv.Atoi(strings.TrimSpace(v)) + return i == 1 + } + return false +} + +var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC) + +func (m *baseMetadata) parseDuration(tagName string) float32 { + if v, ok := m.tags[tagName]; ok { + d, err := time.Parse("15:04:05", v) + if err != nil { + return 0 + } + return float32(d.Sub(zeroTime).Seconds()) + } + return 0 } diff --git a/scanner/metadata/taglib.go b/scanner/metadata/taglib.go new file mode 100644 index 000000000..db509d9ca --- /dev/null +++ b/scanner/metadata/taglib.go @@ -0,0 +1,72 @@ +package metadata + +import ( + "errors" + "os" + + "github.com/deluan/navidrome/log" + "github.com/dhowden/tag" + "github.com/nicksellen/audiotags" +) + +type taglibMetadata struct { + baseMetadata + props *audiotags.AudioProperties + hasPicture bool +} + +func (m *taglibMetadata) Duration() float32 { return float32(m.props.Length) } +func (m *taglibMetadata) BitRate() int { return m.props.Bitrate } +func (m *taglibMetadata) HasPicture() bool { return m.hasPicture } + +type taglibExtractor struct{} + +func (e *taglibExtractor) Extract(paths ...string) (map[string]Metadata, error) { + mds := map[string]Metadata{} + var err error + for _, path := range paths { + md, err := e.extractMetadata(path) + if err == nil { + mds[path] = md + } + } + return mds, err +} + +func (e *taglibExtractor) extractMetadata(filePath string) (*taglibMetadata, error) { + var err error + md := &taglibMetadata{} + md.filePath = filePath + md.fileInfo, err = os.Stat(filePath) + if err != nil { + log.Warn("Error stating file. Skipping", "filePath", filePath, err) + return nil, errors.New("error stating file") + } + md.tags, md.props, err = audiotags.Read(filePath) + if err != nil { + log.Warn("Error reading metadata from file. Skipping", "filePath", filePath, err) + return nil, errors.New("error reading tags") + } + md.hasPicture = hasEmbeddedImage(filePath) + return md, nil +} + +func hasEmbeddedImage(path string) bool { + f, err := os.Open(path) + if err != nil { + log.Warn("Error opening file", "filePath", path, err) + return false + } + defer f.Close() + + m, err := tag.ReadFrom(f) + if err != nil { + log.Warn("Error reading tags from file", "filePath", path, err) + return false + } + + return m.Picture() != nil +} + +var _ Metadata = (*taglibMetadata)(nil) +var _ Extractor = (*taglibExtractor)(nil)