🏡 index : ironmagma/quintodrome.git

package ffmpeg

import (
	"bufio"
	"context"
	"errors"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/navidrome/navidrome/core/ffmpeg"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/scanner/metadata_old"
)

const ExtractorID = "ffmpeg"

type Extractor struct {
	ffmpeg ffmpeg.FFmpeg
}

func (e *Extractor) Parse(files ...string) (map[string]metadata_old.ParsedTags, error) {
	output, err := e.ffmpeg.Probe(context.TODO(), files)
	if err != nil {
		log.Error("Cannot use ffmpeg to extract tags. Aborting", err)
		return nil, err
	}
	fileTags := map[string]metadata_old.ParsedTags{}
	if len(output) == 0 {
		return fileTags, errors.New("error extracting metadata files")
	}
	infos := e.parseOutput(output)
	for file, info := range infos {
		tags, err := e.extractMetadata(file, info)
		// Skip files with errors
		if err == nil {
			fileTags[file] = tags
		}
	}
	return fileTags, nil
}

func (e *Extractor) CustomMappings() metadata_old.ParsedTags {
	return metadata_old.ParsedTags{
		"disc":         {"tpa"},
		"has_picture":  {"metadata_block_picture"},
		"originaldate": {"tdor"},
	}
}

func (e *Extractor) Version() string {
	return e.ffmpeg.Version()
}

func (e *Extractor) extractMetadata(filePath, info string) (metadata_old.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")
	}

	return tags, 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*:(.*)`)

	//                    : Second comment line
	continuationRx = regexp.MustCompile(`(?i)^\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{2,4}Stream #\d+:\d+: Audio:.*, (\d+) kb/s`)

	//    Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s
	//    Stream #0:0: Audio: flac, 44100 Hz, stereo, s16
	//    Stream #0:0: Audio: dsd_lsbf_planar, 352800 Hz, stereo, fltp, 5644 kb/s
	audioStreamRx = regexp.MustCompile(`^\s{2,4}Stream #\d+:\d+.*: Audio: (.*), (.*) Hz, ([\w.]+),*(.*.,)*`)

	//    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{2,4}Stream #\d+:.+: (Video):.*`)
)

func (e *Extractor) 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 *Extractor) parseInfo(info string) map[string][]string {
	tags := map[string][]string{}

	reader := strings.NewReader(info)
	scanner := bufio.NewScanner(reader)
	lastTag := ""
	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]))
			if tagName != "" {
				tagValue := strings.TrimSpace(match[2])
				tags[tagName] = append(tags[tagName], tagValue)
				lastTag = tagName
				continue
			}
		}

		if lastTag != "" {
			match = continuationRx.FindStringSubmatch(line)
			if len(match) > 0 {
				if tags[lastTag] == nil {
					tags[lastTag] = []string{""}
				}
				tagValue := tags[lastTag][0]
				tags[lastTag][0] = tagValue + "\n" + strings.TrimSpace(match[1])
				continue
			}
		}

		lastTag = ""
		match = coverRx.FindStringSubmatch(line)
		if len(match) > 0 {
			tags["has_picture"] = []string{"true"}
			continue
		}

		match = durationRx.FindStringSubmatch(line)
		if len(match) > 0 {
			tags["duration"] = []string{e.parseDuration(match[1])}
			if len(match) > 1 {
				tags["bitrate"] = []string{match[2]}
			}
			continue
		}

		match = bitRateRx.FindStringSubmatch(line)
		if len(match) > 0 {
			tags["bitrate"] = []string{match[1]}
		}

		match = audioStreamRx.FindStringSubmatch(line)
		if len(match) > 0 {
			tags["samplerate"] = []string{match[2]}
			tags["channels"] = []string{e.parseChannels(match[3])}
		}
	}

	comment := tags["comment"]
	if len(comment) > 0 && comment[0] == "Cover (front)" {
		delete(tags, "comment")
	}

	return tags
}

var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC)

func (e *Extractor) parseDuration(tag string) string {
	d, err := time.Parse("15:04:05", tag)
	if err != nil {
		return "0"
	}
	return strconv.FormatFloat(d.Sub(zeroTime).Seconds(), 'f', 2, 32)
}

func (e *Extractor) parseChannels(tag string) string {
	switch tag {
	case "mono":
		return "1"
	case "stereo":
		return "2"
	case "5.1":
		return "6"
	case "7.1":
		return "8"
	default:
		return "0"
	}
}

// Inputs will always be absolute paths
func init() {
	metadata_old.RegisterExtractor(ExtractorID, &Extractor{ffmpeg: ffmpeg.New()})
}