package metadata import ( "bufio" "errors" "os" "os/exec" "regexp" "strings" "github.com/deluan/navidrome/conf" "github.com/deluan/navidrome/log" ) type ffmpegMetadata struct { baseMetadata } 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) DiscNumber() (int, int) { return m.parseTuple("tpa", "disc") } type ffmpegExtractor struct{} func (e *ffmpegExtractor) Extract(files ...string) (map[string]Metadata, error) { args := e.createProbeCommand(files) log.Trace("Executing command", "args", args) cmd := exec.Command(args[0], args[1:]...) // #nosec output, _ := cmd.CombinedOutput() mds := map[string]Metadata{} if len(output) == 0 { return mds, errors.New("error extracting metadata files") } infos := e.parseOutput(string(output)) for file, info := range infos { md, err := e.extractMetadata(file, info) // Skip files with errors if err == nil { mds[file] = md } } return mds, nil } var ( // Input #0, mp3, from 'groovin.mp3': inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`) // TITLE : Back In Black tagsRx = regexp.MustCompile(`(?i)^\s{4,6}([\w\s-]+)\s*:(.*)`) // Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s` durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`) // Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s bitRateRx = regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (Audio):.*, (\d+) 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` coverRx = regexp.MustCompile(`^\s{4}Stream #\d+:\d+: (Video):.*`) ) func (e *ffmpegExtractor) parseOutput(output string) map[string]string { outputs := map[string]string{} all := inputRegex.FindAllStringSubmatchIndex(output, -1) for i, loc := range all { // Filename is the first captured group file := output[loc[2]:loc[3]] // File info is everything from the match, up until the beginning of the next match info := "" initial := loc[1] if i < len(all)-1 { end := all[i+1][0] - 1 info = output[initial:end] } else { // if this is the last match info = output[initial:] } outputs[file] = info } return outputs } func (e *ffmpegExtractor) extractMetadata(filePath, info string) (*ffmpegMetadata, error) { m := &ffmpegMetadata{} m.filePath = filePath m.tags = map[string]string{} var err error m.fileInfo, err = os.Stat(filePath) if err != nil { log.Warn("Error stating file. Skipping", "filePath", filePath, err) return nil, errors.New("error stating file") } m.parseInfo(info) if len(m.tags) == 0 { log.Trace("Not a media file. Skipping", "filePath", filePath) return nil, errors.New("not a media file") } return m, nil } func (m *ffmpegMetadata) parseInfo(info string) { reader := strings.NewReader(info) scanner := bufio.NewScanner(reader) for scanner.Scan() { line := scanner.Text() if len(line) == 0 { continue } match := tagsRx.FindStringSubmatch(line) if len(match) > 0 { tagName := strings.TrimSpace(strings.ToLower(match[1])) tagValue := strings.TrimSpace(match[2]) // Skip when the tag was previously found if _, ok := m.tags[tagName]; !ok { m.tags[tagName] = tagValue } continue } match = coverRx.FindStringSubmatch(line) if len(match) > 0 { m.tags["has_picture"] = "true" continue } match = durationRx.FindStringSubmatch(line) if len(match) > 0 { m.tags["duration"] = match[1] if len(match) > 1 { m.tags["bitrate"] = match[2] } continue } match = bitRateRx.FindStringSubmatch(line) if len(match) > 0 { m.tags["bitrate"] = match[2] } } } // Inputs will always be absolute paths func (e *ffmpegExtractor) createProbeCommand(inputs []string) []string { split := strings.Split(conf.Server.ProbeCommand, " ") args := make([]string, 0) for _, s := range split { if s == "%s" { for _, inp := range inputs { args = append(args, "-i", inp) } } else { args = append(args, s) } } return args }