From 7529ae3752eda5a8025dadcf8a31749f2a0ec395 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 20 Jan 2020 22:32:31 -0500 Subject: [PATCH] Extract metadata with `ffmpeg` --- conf/configuration.go | 7 +- scanner/metadata_ffmpeg.go | 178 +++++++++++++++++++ scanner/{metadata.go => metadata_ffprobe.go} | 2 + scanner/metadata_test.go | 2 +- scanner/scanner_suite_test.go | 2 +- 5 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 scanner/metadata_ffmpeg.go rename scanner/{metadata.go => metadata_ffprobe.go} (99%) diff --git a/conf/configuration.go b/conf/configuration.go index d3e1a1f2..83a288d7 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -17,9 +17,10 @@ type sonic struct { DisableDownsampling bool `default:"false"` DownsampleCommand string `default:"ffmpeg -i %s -map 0:0 -b:a %bk -v 0 -f mp3 -"` - ProbeCommand string `default:"ffprobe -v quiet -print_format json -show_format %s"` - PlsIgnoreFolders bool `default:"true"` - PlsIgnoredPatterns string `default:"^iCloud;\\~"` + //ProbeCommand string `default:"ffprobe -v quiet -print_format json -show_format %s"` + ProbeCommand string `default:"ffmpeg -i %s -f ffmetadata"` + PlsIgnoreFolders bool `default:"true"` + PlsIgnoredPatterns string `default:"^iCloud;\\~"` // DevFlags LogLevel string `default:"info"` diff --git a/scanner/metadata_ffmpeg.go b/scanner/metadata_ffmpeg.go new file mode 100644 index 00000000..d3fef2c9 --- /dev/null +++ b/scanner/metadata_ffmpeg.go @@ -0,0 +1,178 @@ +package scanner + +import ( + "bufio" + "bytes" + "errors" + "mime" + "os" + "os/exec" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/cloudsonic/sonic-server/conf" + "github.com/cloudsonic/sonic-server/log" +) + +type Metadata struct { + filePath string + suffix string + fileInfo os.FileInfo + tags map[string]string +} + +func ExtractMetadata(filePath string) (*Metadata, error) { + m := &Metadata{filePath: filePath, tags: map[string]string{}} + extension := path.Ext(filePath) + if !isAudioFile(extension) { + return nil, errors.New("not an audio file") + } + m.suffix = strings.ToLower(strings.TrimPrefix(extension, ".")) + fi, err := os.Stat(filePath) + if err != nil { + return nil, err + } + m.fileInfo = fi + + err = m.probe(filePath) + if len(m.tags) == 0 { + return nil, errors.New("not a media file") + } + return m, err +} + +func isAudioFile(extension string) bool { + typ := mime.TypeByExtension(extension) + return strings.HasPrefix(typ, "audio/") +} + +func (m *Metadata) Title() string { return m.tags["title"] } +func (m *Metadata) Album() string { return m.tags["album"] } +func (m *Metadata) Artist() string { return m.tags["artist"] } +func (m *Metadata) AlbumArtist() string { return m.tags["album_artist"] } +func (m *Metadata) Composer() string { return m.tags["composer"] } +func (m *Metadata) Genre() string { return m.tags["genre"] } +func (m *Metadata) Year() int { return m.parseInt("year") } +func (m *Metadata) TrackNumber() (int, int) { return m.parseTuple("trackNum") } +func (m *Metadata) DiscNumber() (int, int) { return m.parseTuple("discNum") } +func (m *Metadata) HasPicture() bool { return m.tags["hasPicture"] == "Video" } +func (m *Metadata) Compilation() bool { return m.parseBool("compilation") } +func (m *Metadata) Duration() int { return m.parseDuration("duration") } +func (m *Metadata) BitRate() int { return m.parseInt("bitrate") } +func (m *Metadata) ModificationTime() time.Time { return m.fileInfo.ModTime() } +func (m *Metadata) FilePath() string { return m.filePath } +func (m *Metadata) Suffix() string { return m.suffix } +func (m *Metadata) Size() int { return int(m.fileInfo.Size()) } + +func (m *Metadata) probe(filePath string) error { + cmdLine, args := createProbeCommand(filePath) + + log.Trace("Executing command", "cmdLine", cmdLine, "args", args) + cmd := exec.Command(cmdLine, args...) + output, _ := cmd.CombinedOutput() + if len(output) == 0 || bytes.Contains(output, []byte("No such file or directory")) { + return errors.New("error extracting metadata from " + filePath) + } + return m.parseOutput(output) +} + +var ( + tagsRx = map[*regexp.Regexp]string{ + regexp.MustCompile(`^\s+compilation\s+:(.*)`): "compilation", + regexp.MustCompile(`^\s+genre\s+:\s(.*)`): "genre", + regexp.MustCompile(`^\s+title\s+:\s(.*)`): "title", + regexp.MustCompile(`^\s{4}comment\s+:\s(.*)`): "comment", + regexp.MustCompile(`^\s+artist\s+:\s(.*)`): "artist", + regexp.MustCompile(`^\s+album_artist\s+:\s(.*)`): "album_artist", + regexp.MustCompile(`^\s+TCM\s+:\s(.*)`): "composer", + regexp.MustCompile(`^\s+album\s+:\s(.*)`): "album", + regexp.MustCompile(`^\s+track\s+:\s(.*)`): "trackNum", + regexp.MustCompile(`^\s+disc\s+:\s(.*)`): "discNum", + regexp.MustCompile(`^\s+TPA\s+:\s(.*)`): "discNum", + regexp.MustCompile(`^\s+date\s+:\s(.*)`): "year", + regexp.MustCompile(`^\s{4}Stream #0:1: (.+)\:\s`): "hasPicture", + } + + durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`) +) + +func (m *Metadata) parseOutput(output []byte) error { + reader := strings.NewReader(string(output)) + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + for rx, tag := range tagsRx { + match := rx.FindStringSubmatch(line) + if len(match) > 0 { + m.tags[tag] = match[1] + break + } + match = durationRx.FindStringSubmatch(line) + if len(match) == 0 { + continue + } + m.tags["duration"] = match[1] + if len(match) > 1 { + m.tags["bitrate"] = match[2] + } + } + } + return nil +} + +func (m *Metadata) parseInt(tagName string) int { + if v, ok := m.tags[tagName]; ok { + i, _ := strconv.Atoi(v) + return i + } + return 0 +} + +func (m *Metadata) parseTuple(tagName string) (int, int) { + 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]) + } + return t1, t2 + } + return 0, 0 +} + +func (m *Metadata) parseBool(tagName string) bool { + if v, ok := m.tags[tagName]; ok { + i, _ := strconv.Atoi(v) + return i == 0 + } + return false +} + +var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC) + +func (m *Metadata) parseDuration(tagName string) int { + if v, ok := m.tags[tagName]; ok { + d, err := time.Parse("15:04:05", v) + if err != nil { + return 0 + } + return int(d.Sub(zeroTime).Seconds()) + } + return 0 +} + +func createProbeCommand(filePath string) (string, []string) { + cmd := conf.Sonic.ProbeCommand + + split := strings.Split(cmd, " ") + for i, s := range split { + s = strings.Replace(s, "%s", filePath, -1) + split[i] = s + } + + return split[0], split[1:] +} diff --git a/scanner/metadata.go b/scanner/metadata_ffprobe.go similarity index 99% rename from scanner/metadata.go rename to scanner/metadata_ffprobe.go index 93aa67d4..8975dc58 100644 --- a/scanner/metadata.go +++ b/scanner/metadata_ffprobe.go @@ -1,3 +1,5 @@ +//+build ignored + package scanner import ( diff --git a/scanner/metadata_test.go b/scanner/metadata_test.go index 31e650f3..2df16274 100644 --- a/scanner/metadata_test.go +++ b/scanner/metadata_test.go @@ -43,7 +43,7 @@ var _ = Describe("Metadata", func() { Expect(m.Size()).To(Equal(4408)) }) - It("returns error for invalid media file", func() { + FIt("returns error for invalid media file", func() { _, err := ExtractMetadata("../tests/fixtures/itunes-library.xml") Expect(err).ToNot(BeNil()) }) diff --git a/scanner/scanner_suite_test.go b/scanner/scanner_suite_test.go index 2adf1a35..26dc7714 100644 --- a/scanner/scanner_suite_test.go +++ b/scanner/scanner_suite_test.go @@ -18,7 +18,7 @@ func xTestScanner(t *testing.T) { RunSpecs(t, "Scanner Suite") } -var _ = Describe("TODO: REMOVE", func() { +var _ = XDescribe("TODO: REMOVE", func() { conf.Sonic.DbPath = "./testDB" log.SetLevel(log.LevelDebug) ds := persistence.New()