refactor(scanner): remove the unused legacy ffmpeg metadata extractor (#6231)

* refactor(scanner): remove the legacy ffmpeg metadata extractor

The ffmpeg extractor in scanner/metadata_old has not been wired into the scanner since the taglib-only rewrite, so it was only exercised by its own tests. Remove the package, the FFmpeg.Probe method and its ffmetadata command that only it used, and the startup fallback for Scanner.Extractor="ffmpeg". Configs that still set it keep working: unknown extractors already fall back to taglib with a warning.

* fix(conf): warn and fall back to taglib for an unknown Scanner.Extractor

Validate the option when loading the config, so invalid values such as the removed "ffmpeg" extractor are reported once at startup instead of only when a library storage is created.
This commit is contained in:
Deluan Quintão 2026-09-26 12:30:52 -04:00 • committed by GitHub
commit bb7d81a5ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 30 additions and 1320 deletions

View file

@ -514,6 +514,11 @@ func Load(noConfigDump bool) {
Server.UICoverArtSize = newValue
}
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
Server.Scanner.Extractor = consts.DefaultScannerExtractor
}
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)

View file

@ -402,6 +402,27 @@ var _ = Describe("Configuration", func() {
})
})
Describe("Scanner.Extractor", func() {
BeforeEach(func() {
viper.Reset()
conf.SetViperDefaults()
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("loglevel", "error")
conf.ResetConf()
})
It("falls back to taglib for an unknown extractor", func() {
viper.SetDefault("scanner.extractor", "ffmpeg")
conf.Load(true)
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
})
It("keeps taglib", func() {
conf.Load(true)
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
})
})
Describe("EnforceNonRootUser", func() {
It("defaults to false", func() {
conf.Load(true)

View file

@ -49,7 +49,6 @@ type FFmpeg interface {
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error)
Probe(ctx context.Context, files []string) (string, error)
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
CmdPath() (string, error)
IsAvailable() bool
@ -68,7 +67,6 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder
const (
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
probeCmd = "ffmpeg %s -f ffmetadata"
probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s"
)
@ -149,17 +147,6 @@ func fileExists(path string) error {
return nil
}
func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
if _, err := ffmpegCmd(); err != nil {
return "", err
}
args := createProbeCommand(probeCmd, files)
log.Trace(ctx, "Executing ffmpeg command", "args", args)
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
output, _ := cmd.CombinedOutput()
return string(output), nil
}
func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
@ -593,20 +580,6 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
return args
}
func createProbeCommand(cmd string, inputs []string) []string {
var args []string
for _, s := range fixCmd(cmd) {
if s == "%s" {
for _, inp := range inputs {
args = append(args, "-i", inp)
}
} else {
args = append(args, s)
}
}
return args
}
func fixCmd(cmd string) []string {
split := strings.Fields(cmd)
cmdPath, _ := ffmpegCmd()

View file

@ -62,23 +62,16 @@ var _ = Describe("ffmpeg", func() {
})
})
Describe("createProbeCommand", func() {
It("creates a valid command line", func() {
args := createProbeCommand(probeCmd, []string{"/music library/one.mp3", "/music library/two.mp3"})
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"}))
})
})
When("ffmpegPath is set", func() {
It("returns the correct ffmpeg path", func() {
ffmpegPath = "/usr/bin/ffmpeg"
args := createProbeCommand(probeCmd, []string{"one.mp3"})
Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "ffmetadata"}))
args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0)
Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "mp3", "-"}))
})
It("returns the correct ffmpeg path with spaces", func() {
ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe"
args := createProbeCommand(probeCmd, []string{"one.mp3"})
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0)
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "mp3", "-"}))
})
})

View file

@ -1,211 +0,0 @@
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()})
}

View file

@ -1,17 +0,0 @@
package ffmpeg
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestFFMpeg(t *testing.T) {
tests.Init(t, true)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "FFMpeg Suite")
}

View file

@ -1,375 +0,0 @@
package ffmpeg
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Extractor", func() {
var e *Extractor
BeforeEach(func() {
e = &Extractor{}
})
Context("extractMetadata", func() {
It("extracts MusicBrainz custom tags", func() {
const output = `
Input #0, ape, from './Capture/02 01 - Symphony No. 5 in C minor, Op. 67 I. Allegro con brio - Ludwig van Beethoven.ape':
Metadata:
ALBUM : Forever Classics
ARTIST : Ludwig van Beethoven
TITLE : Symphony No. 5 in C minor, Op. 67: I. Allegro con brio
MUSICBRAINZ_ALBUMSTATUS: official
MUSICBRAINZ_ALBUMTYPE: album
MusicBrainz_AlbumComment: MP3
Musicbrainz_Albumid: 71eb5e4a-90e2-4a31-a2d1-a96485fcb667
musicbrainz_trackid: ffe06940-727a-415a-b608-b7e45737f9d8
Musicbrainz_Artistid: 1f9df192-a621-4f54-8850-2c5373b7eac9
Musicbrainz_Albumartistid: 89ad4ac3-39f7-470e-963a-56509c546377
Musicbrainz_Releasegroupid: 708b1ae1-2d3d-34c7-b764-2732b154f5b6
musicbrainz_releasetrackid: 6fee2e35-3049-358f-83be-43b36141028b
CatalogNumber : PLD 1201
`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(SatisfyAll(
HaveKeyWithValue("catalognumber", []string{"PLD 1201"}),
HaveKeyWithValue("musicbrainz_trackid", []string{"ffe06940-727a-415a-b608-b7e45737f9d8"}),
HaveKeyWithValue("musicbrainz_albumid", []string{"71eb5e4a-90e2-4a31-a2d1-a96485fcb667"}),
HaveKeyWithValue("musicbrainz_artistid", []string{"1f9df192-a621-4f54-8850-2c5373b7eac9"}),
HaveKeyWithValue("musicbrainz_albumartistid", []string{"89ad4ac3-39f7-470e-963a-56509c546377"}),
HaveKeyWithValue("musicbrainz_albumtype", []string{"album"}),
HaveKeyWithValue("musicbrainz_albumcomment", []string{"MP3"}),
))
})
It("detects embedded cover art correctly", 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:00:01.02, start: 0.000000, bitrate: 477 kb/s
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).To(HaveKeyWithValue("has_picture", []string{"true"}))
})
It("detects embedded cover art in ffmpeg 4.4 output", func() {
const output = `
Input #0, flac, from '/run/media/naomi/Archivio/Musica/Katy Perry/Chained to the Rhythm/01 Katy Perry featuring Skip Marley - Chained to the Rhythm.flac':
Metadata:
ARTIST : Katy Perry featuring Skip Marley
Duration: 00:03:57.91, start: 0.000000, bitrate: 983 kb/s
Stream #0:0: Audio: flac, 44100 Hz, stereo, s16
Stream #0:1: Video: mjpeg (Baseline), yuvj444p(pc, bt470bg/unknown/unknown), 599x518, 90k tbr, 90k tbn, 90k tbc (attached pic)
Metadata:
comment : Cover (front)`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("has_picture", []string{"true"}))
})
It("detects embedded cover art in ogg containers", func() {
const output = `
Input #0, ogg, from '/Users/deluan/Music/iTunes/iTunes Media/Music/_Testes/Jamaican In New York/01-02 Jamaican In New York (Album Version).opus':
Duration: 00:04:28.69, start: 0.007500, bitrate: 139 kb/s
Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp
Metadata:
ALBUM : Jamaican In New York
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).To(HaveKey("metadata_block_picture"))
md = md.Map(e.CustomMappings())
Expect(md).To(HaveKey("has_picture"))
})
It("detects embedded cover art in m4a containers", func() {
const output = `
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'Putumayo Presents_ Euro Groove/01 Destins et Désirs.m4a':
Metadata:
album : Putumayo Presents: Euro Groove
Duration: 00:05:15.81, start: 0.047889, bitrate: 133 kb/s
Stream #0:0[0x1](und): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 125 kb/s (default)
Metadata:
creation_time : 2008-03-11T21:03:23.000000Z
vendor_id : [0][0][0][0]
Stream #0:1[0x0]: Video: png, rgb24(pc), 350x350, 90k tbr, 90k tbn (attached pic)
`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("has_picture", []string{"true"}))
})
It("gets bitrate from the stream, if available", func() {
const output = `
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
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).To(HaveKeyWithValue("bitrate", []string{"192"}))
})
It("parses duration with milliseconds", func() {
const output = `
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).To(HaveKeyWithValue("duration", []string{"302.63"}))
})
It("parse flac bitrates", func() {
const output = `
Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3':
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).To(HaveKeyWithValue("channels", []string{"2"}))
})
It("parse channels from the stream with bitrate", func() {
const output = `
Input #0, flac, from '/Users/deluan/Music/Music/Media/__/Crazy For You/01-01 Crazy For You.flac':
Metadata:
TITLE : Crazy For You
Duration: 00:04:13.00, start: 0.000000, bitrate: 852 kb/s
Stream #0:0: Audio: flac, 44100 Hz, stereo, s16
Stream #0:1: Video: mjpeg (Progressive), yuvj444p(pc, bt470bg/unknown/unknown), 600x600, 90k tbr, 90k tbn, 90k tbc (attached pic)
`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("bitrate", []string{"852"}))
})
It("parse 7.1 channels from the stream", func() {
const output = `
Input #0, wav, from '/Users/deluan/Music/Music/Media/_/multichannel/Nums_7dot1_24_48000.wav':
Duration: 00:00:09.05, bitrate: 9216 kb/s
Stream #0:0: Audio: pcm_s24le ([1][0][0][0] / 0x0001), 48000 Hz, 7.1, s32 (24 bit), 9216 kb/s`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("channels", []string{"8"}))
})
It("parse channels from the stream without bitrate", func() {
const output = `
Input #0, flac, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.flac':
Duration: 00:00:01.02, start: 0.000000, bitrate: 1371 kb/s
Stream #0:0: Audio: flac, 44100 Hz, stereo, fltp, s32 (24 bit)`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("channels", []string{"2"}))
})
It("parse channels from the stream with lang", func() {
const output = `
Input #0, flac, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.m4a':
Duration: 00:00:01.02, start: 0.000000, bitrate: 1371 kb/s
Stream #0:0(eng): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 262 kb/s (default)`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("channels", []string{"2"}))
})
It("parse channels from the stream with lang 2", func() {
const output = `
Input #0, flac, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.m4a':
Duration: 00:00:01.02, start: 0.000000, bitrate: 1371 kb/s
Stream #0:0(eng): Audio: vorbis, 44100 Hz, stereo, fltp, 192 kb/s`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("channels", []string{"2"}))
})
It("parse sampleRate from the stream", func() {
const output = `
Input #0, dsf, from '/Users/deluan/Downloads/06-04 Perpetual Change.dsf':
Duration: 00:14:19.46, start: 0.000000, bitrate: 5644 kb/s
Stream #0:0: Audio: dsd_lsbf_planar, 352800 Hz, stereo, fltp, 5644 kb/s`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("samplerate", []string{"352800"}))
})
It("parse sampleRate from the stream", func() {
const output = `
Input #0, wav, from '/Users/deluan/Music/Music/Media/_/multichannel/Nums_7dot1_24_48000.wav':
Duration: 00:00:09.05, bitrate: 9216 kb/s
Stream #0:0: Audio: pcm_s24le ([1][0][0][0] / 0x0001), 48000 Hz, 7.1, s32 (24 bit), 9216 kb/s`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("samplerate", []string{"48000"}))
})
It("parses stream level tags", func() {
const output = `
Input #0, ogg, from './01-02 Drive (Teku).opus':
Metadata:
ALBUM : Hot Wheels Acceleracers Soundtrack
Duration: 00:03:37.37, start: 0.007500, bitrate: 135 kb/s
Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp
Metadata:
TITLE : Drive (Teku)`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("title", []string{"Drive (Teku)"}))
})
It("does not overlap top level tags with the stream level tags", func() {
const output = `
Input #0, mp3, from 'groovin.mp3':
Metadata:
title : Groovin' (feat. Daniel Sneijers, Susanne Alt)
Duration: 00:03:34.28, start: 0.025056, bitrate: 323 kb/s
Metadata:
title : garbage`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("title", []string{"Groovin' (feat. Daniel Sneijers, Susanne Alt)", "garbage"}))
})
It("parses multiline tags", func() {
const outputWithMultilineComment = `
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'modulo.m4a':
Metadata:
comment : https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/
:
: Tracklist:
:
: 01. Saara Saara
: 02. Carta Corrente
: 03. X
: 04. Eclipse Lunar
: 05. Vírus de Sírius
: 06. Doktor Fritz
: 07. Wunderbar
: 08. Quarta Dimensão
Duration: 00:26:46.96, start: 0.052971, bitrate: 69 kb/s`
const expectedComment = `https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/
Tracklist:
01. Saara Saara
02. Carta Corrente
03. X
04. Eclipse Lunar
05. Vírus de Sírius
06. Doktor Fritz
07. Wunderbar
08. Quarta Dimensão`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", outputWithMultilineComment)
Expect(md).To(HaveKeyWithValue("comment", []string{expectedComment}))
})
It("parses sort tags correctly", func() {
const output = `
Input #0, mp3, from '/Users/deluan/Downloads/椎名林檎 - 加爾基 精液 栗ノ花 - 2003/02 - ドツペルゲンガー.mp3':
Metadata:
title-sort : Dopperugengā
album : 加爾基 精液 栗ノ花
artist : 椎名林檎
album_artist : 椎名林檎
title : ドツペルゲンガー
albumsort : Kalk Samen Kuri No Hana
artist_sort : Shiina, Ringo
ALBUMARTISTSORT : Shiina, Ringo
`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(SatisfyAll(
HaveKeyWithValue("title", []string{"ドツペルゲンガー"}),
HaveKeyWithValue("album", []string{"加爾基 精液 栗ノ花"}),
HaveKeyWithValue("artist", []string{"椎名林檎"}),
HaveKeyWithValue("album_artist", []string{"椎名林檎"}),
HaveKeyWithValue("title-sort", []string{"Dopperugengā"}),
HaveKeyWithValue("albumsort", []string{"Kalk Samen Kuri No Hana"}),
HaveKeyWithValue("artist_sort", []string{"Shiina, Ringo"}),
HaveKeyWithValue("albumartistsort", []string{"Shiina, Ringo"}),
))
})
It("ignores cover comment", func() {
const output = `
Input #0, mp3, from './Edie Brickell/Picture Perfect Morning/01-01 Tomorrow Comes.mp3':
Metadata:
title : Tomorrow Comes
artist : Edie Brickell
Duration: 00:03:56.12, start: 0.000000, bitrate: 332 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, s16p, 320 kb/s
Stream #0:1: Video: mjpeg, yuvj420p(pc, bt470bg/unknown/unknown), 1200x1200 [SAR 72:72 DAR 1:1], 90k tbr, 90k tbn, 90k tbc
Metadata:
comment : Cover (front)`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).ToNot(HaveKey("comment"))
})
It("parses tags with spaces in the name", func() {
const output = `
Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hut, to the Projects, to the Mansion/10 - The Struggle (interlude).mp3':
Metadata:
ALBUM ARTIST : Wyclef Jean
`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("album artist", []string{"Wyclef Jean"}))
})
})
It("parses an integer TBPM tag", func() {
const output = `
Input #0, mp3, from 'tests/fixtures/test.mp3':
Metadata:
TBPM : 123`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("tbpm", []string{"123"}))
})
It("parses and rounds a floating point fBPM tag", func() {
const output = `
Input #0, ogg, from 'tests/fixtures/test.ogg':
Metadata:
FBPM : 141.7`
md, _ := e.extractMetadata("tests/fixtures/test.ogg", output)
Expect(md).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
})
It("parses replaygain data correctly", func() {
const output = `
Input #0, mp3, from 'test.mp3':
Metadata:
REPLAYGAIN_ALBUM_PEAK: 0.9125
REPLAYGAIN_TRACK_PEAK: 0.4512
REPLAYGAIN_TRACK_GAIN: -1.48 dB
REPLAYGAIN_ALBUM_GAIN: +3.21518 dB
Side data:
replaygain: track gain - -1.480000, track peak - 0.000011, album gain - 3.215180, album peak - 0.000021,
`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(SatisfyAll(
HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}),
HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}),
HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}),
HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}),
))
})
It("parses lyrics with language code", func() {
const output = `
Input #0, mp3, from 'test.mp3':
Metadata:
lyrics-eng : [00:00.00]This is
: [00:02.50]English
lyrics-xxx : [00:00.00]This is
: [00:02.50]unspecified
`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(SatisfyAll(
HaveKeyWithValue("lyrics-eng", []string{
"[00:00.00]This is\n[00:02.50]English",
}),
HaveKeyWithValue("lyrics-xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
}),
))
})
It("parses normal LYRICS tag", func() {
const output = `
Input #0, mp3, from 'test.mp3':
Metadata:
LYRICS : [00:00.00]This is
: [00:02.50]English
`
md, _ := e.extractMetadata("tests/fixtures/test.mp3", output)
Expect(md).To(HaveKeyWithValue("lyrics", []string{
"[00:00.00]This is\n[00:02.50]English",
}))
})
})

View file

@ -1,411 +0,0 @@
package metadata_old
import (
"context"
"encoding/json"
"fmt"
"math"
"os"
"path"
"regexp"
"strconv"
"strings"
"time"
"github.com/djherbis/times"
"github.com/google/uuid"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
)
type Extractor interface {
Parse(files ...string) (map[string]ParsedTags, error)
CustomMappings() ParsedTags
Version() string
}
var extractors = map[string]Extractor{}
func RegisterExtractor(id string, parser Extractor) {
extractors[id] = parser
}
func LogExtractors() {
for id, p := range extractors {
log.Debug("Registered metadata extractor", "id", id, "version", p.Version())
}
}
func Extract(files ...string) (map[string]Tags, error) {
p, ok := extractors[conf.Server.Scanner.Extractor]
if !ok {
log.Warn("Invalid 'Scanner.Extractor' option. Using default", "requested", conf.Server.Scanner.Extractor,
"validOptions", "ffmpeg,taglib", "default", consts.DefaultScannerExtractor)
p = extractors[consts.DefaultScannerExtractor]
}
extractedTags, err := p.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
}
tags = tags.Map(p.CustomMappings())
result[filePath] = NewTag(filePath, fileInfo, tags)
}
return result, nil
}
func NewTag(filePath string, fileInfo os.FileInfo, tags ParsedTags) Tags {
for t, values := range tags {
values = removeDuplicatesAndEmpty(values)
if len(values) == 0 {
delete(tags, t)
continue
}
tags[t] = values
}
return Tags{
filePath: filePath,
fileInfo: fileInfo,
Tags: tags,
}
}
func removeDuplicatesAndEmpty(values []string) []string {
encountered := map[string]struct{}{}
empty := true
result := make([]string, 0, len(values))
for _, v := range values {
if _, ok := encountered[v]; ok {
continue
}
encountered[v] = struct{}{}
empty = empty && v == ""
result = append(result, v)
}
if empty {
return nil
}
return result
}
type ParsedTags map[string][]string
func (p ParsedTags) Map(customMappings ParsedTags) ParsedTags {
if customMappings == nil {
return p
}
for tagName, alternatives := range customMappings {
for _, altName := range alternatives {
if altValue, ok := p[altName]; ok {
p[tagName] = append(p[tagName], altValue...)
delete(p, altName)
}
}
}
return p
}
type Tags struct {
filePath string
fileInfo os.FileInfo
Tags ParsedTags
}
// Common tags
func (t Tags) Title() string { return t.getFirstTagValue("title", "sort_name", "titlesort") }
func (t Tags) Album() string { return t.getFirstTagValue("album", "sort_album", "albumsort") }
func (t Tags) Artist() string { return t.getFirstTagValue("artist", "sort_artist", "artistsort") }
func (t Tags) AlbumArtist() string {
return t.getFirstTagValue("album_artist", "album artist", "albumartist")
}
func (t Tags) SortTitle() string { return t.getSortTag("tsot", "title", "name") }
func (t Tags) SortAlbum() string { return t.getSortTag("tsoa", "album") }
func (t Tags) SortArtist() string { return t.getSortTag("tsop", "artist") }
func (t Tags) SortAlbumArtist() string { return t.getSortTag("tso2", "albumartist", "album_artist") }
func (t Tags) Genres() []string { return t.getAllTagValues("genre") }
func (t Tags) Date() (int, string) { return t.getDate("date") }
func (t Tags) OriginalDate() (int, string) { return t.getDate("originaldate") }
func (t Tags) ReleaseDate() (int, string) { return t.getDate("releasedate") }
func (t Tags) Comment() string { return t.getFirstTagValue("comment") }
func (t Tags) Compilation() bool { return t.getBool("tcmp", "compilation", "wm/iscompilation") }
func (t Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") }
func (t Tags) DiscNumber() (int, int) { return t.getTuple("disc", "discnumber") }
func (t Tags) DiscSubtitle() string {
return t.getFirstTagValue("tsst", "discsubtitle", "setsubtitle")
}
func (t Tags) CatalogNum() string { return t.getFirstTagValue("catalognumber") }
func (t Tags) Bpm() int { return (int)(math.Round(t.getFloat("tbpm", "bpm", "fbpm"))) }
func (t Tags) HasPicture() bool { return t.getFirstTagValue("has_picture") != "" }
// MusicBrainz Identifiers
func (t Tags) MbzReleaseTrackID() string {
return t.getMbzID("musicbrainz_releasetrackid", "musicbrainz release track id")
}
func (t Tags) MbzRecordingID() string {
return t.getMbzID("musicbrainz_trackid", "musicbrainz track id")
}
func (t Tags) MbzAlbumID() string { return t.getMbzID("musicbrainz_albumid", "musicbrainz album id") }
func (t Tags) MbzArtistID() string {
return t.getMbzID("musicbrainz_artistid", "musicbrainz artist id")
}
func (t Tags) MbzAlbumArtistID() string {
return t.getMbzID("musicbrainz_albumartistid", "musicbrainz album artist id")
}
func (t Tags) MbzAlbumType() string {
return t.getFirstTagValue("musicbrainz_albumtype", "musicbrainz album type")
}
func (t Tags) MbzAlbumComment() string {
return t.getFirstTagValue("musicbrainz_albumcomment", "musicbrainz album comment")
}
// Gain Properties
func (t Tags) RGAlbumGain() float64 {
return t.getGainValue("replaygain_album_gain", "r128_album_gain")
}
func (t Tags) RGAlbumPeak() float64 { return t.getPeakValue("replaygain_album_peak") }
func (t Tags) RGTrackGain() float64 {
return t.getGainValue("replaygain_track_gain", "r128_track_gain")
}
func (t Tags) RGTrackPeak() float64 { return t.getPeakValue("replaygain_track_peak") }
// File properties
func (t Tags) Duration() float32 { return float32(t.getFloat("duration")) }
func (t Tags) SampleRate() int { return t.getInt("samplerate") }
func (t Tags) BitRate() int { return t.getInt("bitrate") }
func (t Tags) Channels() int { return t.getInt("channels") }
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 strings.ToLower(strings.TrimPrefix(path.Ext(t.filePath), ".")) }
func (t Tags) BirthTime() time.Time {
if ts := times.Get(t.fileInfo); ts.HasBirthTime() {
return ts.BirthTime()
}
return time.Now()
}
func (t Tags) Lyrics() string {
lyricList := model.LyricList{}
basicLyrics := t.getAllTagValues("lyrics", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics")
for _, value := range basicLyrics {
parsed, err := model.ParseLyrics(context.Background(), ".lrc", "xxx", []byte(value))
if err != nil {
log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err)
continue
}
if main, ok := parsed.Main(); ok {
lyricList = append(lyricList, main)
}
}
for tag, value := range t.Tags {
if after, ok := strings.CutPrefix(tag, "lyrics-"); ok {
language := strings.TrimSpace(after)
if language == "" {
language = "xxx"
}
for _, text := range value {
parsed, err := model.ParseLyrics(context.Background(), ".lrc", language, []byte(text))
if err != nil {
log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err)
continue
}
if main, ok := parsed.Main(); ok {
lyricList = append(lyricList, main)
}
}
}
}
res, err := json.Marshal(lyricList)
if err != nil {
log.Warn("Unexpected error occurred when serializing lyrics", "file", t.filePath, "error", err)
return ""
}
return string(res)
}
func (t Tags) getGainValue(rgTagName, r128TagName string) float64 {
// Check for ReplayGain first
// ReplayGain is in the form [-]a.bb dB and normalized to -18dB
var tag = t.getFirstTagValue(rgTagName)
if tag != "" {
tag = strings.TrimSpace(strings.Replace(tag, "dB", "", 1))
var value, err = strconv.ParseFloat(tag, 64)
if err != nil || value == math.Inf(-1) || value == math.Inf(1) {
return 0
}
return value
}
// If ReplayGain is not found, check for R128 gain
// R128 gain is a Q7.8 fixed point number normalized to -23dB
tag = t.getFirstTagValue(r128TagName)
if tag != "" {
var iValue, err = strconv.Atoi(tag)
if err != nil {
return 0
}
// Convert Q7.8 to float
var value = float64(iValue) / 256.0
// Adding 5 dB to normalize with ReplayGain level
return value + 5
}
return 0
}
func (t Tags) getPeakValue(tagName string) float64 {
var tag = t.getFirstTagValue(tagName)
var value, err = strconv.ParseFloat(tag, 64)
if err != nil || value == math.Inf(-1) || value == math.Inf(1) {
// A default of 1 for peak value results in no changes
return 1
}
return value
}
func (t Tags) getTags(tagNames ...string) []string {
for _, tag := range tagNames {
if v, ok := t.Tags[tag]; ok {
return v
}
}
return nil
}
func (t Tags) getFirstTagValue(tagNames ...string) string {
ts := t.getTags(tagNames...)
if len(ts) > 0 {
return ts[0]
}
return ""
}
func (t Tags) getAllTagValues(tagNames ...string) []string {
values := make([]string, 0, len(tagNames)*2)
for _, tag := range tagNames {
if v, ok := t.Tags[tag]; ok {
values = append(values, v...)
}
}
return values
}
func (t Tags) getSortTag(originalTag string, tagNames ...string) string {
formats := []string{"sort%s", "sort_%s", "sort-%s", "%ssort", "%s_sort", "%s-sort"}
all := make([]string, 1, len(tagNames)*len(formats)+1)
all[0] = originalTag
for _, tag := range tagNames {
for _, format := range formats {
name := fmt.Sprintf(format, tag)
all = append(all, name)
}
}
return t.getFirstTagValue(all...)
}
var dateRegex = regexp.MustCompile(`([12]\d\d\d)`)
func (t Tags) getDate(tagNames ...string) (int, string) {
tag := t.getFirstTagValue(tagNames...)
if len(tag) < 4 {
return 0, ""
}
// first get just the year
match := dateRegex.FindStringSubmatch(tag)
if len(match) == 0 {
log.Warn("Error parsing "+tagNames[0]+" field for year", "file", t.filePath, "date", tag)
return 0, ""
}
year, _ := strconv.Atoi(match[1])
if len(tag) < 5 {
return year, match[1]
}
//then try YYYY-MM-DD
if len(tag) > 10 {
tag = tag[:10]
}
layout := "2006-01-02"
_, err := time.Parse(layout, tag)
if err != nil {
layout = "2006-01"
_, err = time.Parse(layout, tag)
if err != nil {
log.Warn("Error parsing "+tagNames[0]+" field for month + day", "file", t.filePath, "date", tag)
return year, match[1]
}
}
return year, tag
}
func (t Tags) getBool(tagNames ...string) bool {
tag := t.getFirstTagValue(tagNames...)
if tag == "" {
return false
}
i, _ := strconv.Atoi(strings.TrimSpace(tag))
return i == 1
}
func (t Tags) getTuple(tagNames ...string) (int, int) {
tag := t.getFirstTagValue(tagNames...)
if tag == "" {
return 0, 0
}
tuple := strings.Split(tag, "/")
t1, t2 := 0, 0
t1, _ = strconv.Atoi(tuple[0])
if len(tuple) > 1 {
t2, _ = strconv.Atoi(tuple[1])
} else {
t2tag := t.getFirstTagValue(tagNames[0] + "total")
t2, _ = strconv.Atoi(t2tag)
}
return t1, t2
}
func (t Tags) getMbzID(tagNames ...string) string {
tag := t.getFirstTagValue(tagNames...)
if _, err := uuid.Parse(tag); err != nil {
return ""
}
return tag
}
func (t Tags) getInt(tagNames ...string) int {
tag := t.getFirstTagValue(tagNames...)
i, _ := strconv.Atoi(tag)
return i
}
func (t Tags) getFloat(tagNames ...string) float64 {
var tag = t.getFirstTagValue(tagNames...)
var value, err = strconv.ParseFloat(tag, 64)
if err != nil {
return 0
}
return value
}

View file

@ -1,144 +0,0 @@
package metadata_old
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Tags", func() {
DescribeTable("getDate",
func(tag string, expectedYear int, expectedDate string) {
md := &Tags{}
md.Tags = map[string][]string{"date": {tag}}
testYear, testDate := md.Date()
Expect(testYear).To(Equal(expectedYear))
Expect(testDate).To(Equal(expectedDate))
},
Entry(nil, "1985", 1985, "1985"),
Entry(nil, "2002-01", 2002, "2002-01"),
Entry(nil, "1969.06", 1969, "1969"),
Entry(nil, "1980.07.25", 1980, "1980"),
Entry(nil, "2004-00-00", 2004, "2004"),
Entry(nil, "2016-12-31", 2016, "2016-12-31"),
Entry(nil, "2013-May-12", 2013, "2013"),
Entry(nil, "May 12, 2016", 2016, "2016"),
Entry(nil, "01/10/1990", 1990, "1990"),
Entry(nil, "invalid", 0, ""),
)
Describe("getMbzID", func() {
It("return a valid MBID", func() {
md := &Tags{}
md.Tags = map[string][]string{
"musicbrainz_trackid": {"8f84da07-09a0-477b-b216-cc982dabcde1"},
"musicbrainz_releasetrackid": {"6caf16d3-0b20-3fe6-8020-52e31831bc11"},
"musicbrainz_albumid": {"f68c985d-f18b-4f4a-b7f0-87837cf3fbf9"},
"musicbrainz_artistid": {"89ad4ac3-39f7-470e-963a-56509c546377"},
"musicbrainz_albumartistid": {"ada7a83c-e3e1-40f1-93f9-3e73dbc9298a"},
}
Expect(md.MbzRecordingID()).To(Equal("8f84da07-09a0-477b-b216-cc982dabcde1"))
Expect(md.MbzReleaseTrackID()).To(Equal("6caf16d3-0b20-3fe6-8020-52e31831bc11"))
Expect(md.MbzAlbumID()).To(Equal("f68c985d-f18b-4f4a-b7f0-87837cf3fbf9"))
Expect(md.MbzArtistID()).To(Equal("89ad4ac3-39f7-470e-963a-56509c546377"))
Expect(md.MbzAlbumArtistID()).To(Equal("ada7a83c-e3e1-40f1-93f9-3e73dbc9298a"))
})
It("return empty string for invalid MBID", func() {
md := &Tags{}
md.Tags = map[string][]string{
"musicbrainz_trackid": {"11406732-6"},
"musicbrainz_albumid": {"11406732"},
"musicbrainz_artistid": {"200455"},
"musicbrainz_albumartistid": {"194"},
}
Expect(md.MbzRecordingID()).To(Equal(""))
Expect(md.MbzAlbumID()).To(Equal(""))
Expect(md.MbzArtistID()).To(Equal(""))
Expect(md.MbzAlbumArtistID()).To(Equal(""))
})
})
Describe("getAllTagValues", func() {
It("returns values from all tag names", func() {
md := &Tags{}
md.Tags = map[string][]string{
"genre": {"Rock", "Pop", "New Wave"},
}
Expect(md.Genres()).To(ConsistOf("Rock", "Pop", "New Wave"))
})
})
Describe("removeDuplicatesAndEmpty", func() {
It("removes duplicates", func() {
md := NewTag("/music/artist/album01/Song.mp3", nil, ParsedTags{
"genre": []string{"pop", "rock", "pop"},
"date": []string{"2023-03-01", "2023-03-01"},
"mood": []string{"happy", "sad"},
})
Expect(md.Tags).To(HaveKeyWithValue("genre", []string{"pop", "rock"}))
Expect(md.Tags).To(HaveKeyWithValue("date", []string{"2023-03-01"}))
Expect(md.Tags).To(HaveKeyWithValue("mood", []string{"happy", "sad"}))
})
It("removes empty tags", func() {
md := NewTag("/music/artist/album01/Song.mp3", nil, ParsedTags{
"genre": []string{"pop", "rock", "pop"},
"mood": []string{"", ""},
})
Expect(md.Tags).To(HaveKeyWithValue("genre", []string{"pop", "rock"}))
Expect(md.Tags).ToNot(HaveKey("mood"))
})
})
Describe("BPM", func() {
var t *Tags
BeforeEach(func() {
t = &Tags{Tags: map[string][]string{
"fbpm": {"141.7"},
}}
})
It("rounds a floating point fBPM tag", func() {
Expect(t.Bpm()).To(Equal(142))
})
})
Describe("ReplayGain", func() {
DescribeTable("getGainValue",
func(tag string, expected float64) {
md := &Tags{}
md.Tags = map[string][]string{"replaygain_track_gain": {tag}}
Expect(md.RGTrackGain()).To(Equal(expected))
},
Entry("0", "0", 0.0),
Entry("1.2dB", "1.2dB", 1.2),
Entry("Infinity", "Infinity", 0.0),
Entry("Invalid value", "INVALID VALUE", 0.0),
)
DescribeTable("getPeakValue",
func(tag string, expected float64) {
md := &Tags{}
md.Tags = map[string][]string{"replaygain_track_peak": {tag}}
Expect(md.RGTrackPeak()).To(Equal(expected))
},
Entry("0", "0", 0.0),
Entry("0.5", "0.5", 0.5),
Entry("Invalid dB suffix", "0.7dB", 1.0),
Entry("Infinity", "Infinity", 1.0),
Entry("Invalid value", "INVALID VALUE", 1.0),
)
DescribeTable("getR128GainValue",
func(tag string, expected float64) {
md := &Tags{}
md.Tags = map[string][]string{"r128_track_gain": {tag}}
Expect(md.RGTrackGain()).To(Equal(expected))
},
Entry("0", "0", 5.0),
Entry("-3776", "-3776", -9.75),
Entry("Infinity", "Infinity", 0.0),
Entry("Invalid value", "INVALID VALUE", 0.0),
)
})
})

View file

@ -1,17 +0,0 @@
package metadata_old
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestMetadata(t *testing.T) {
tests.Init(t, true)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Metadata Suite")
}

View file

@ -1,95 +0,0 @@
package metadata_old_test
import (
"cmp"
"encoding/json"
"slices"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/scanner/metadata_old"
_ "github.com/navidrome/navidrome/scanner/metadata_old/ffmpeg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Tags", func() {
var zero int64 = 0
var secondTs int64 = 2500
makeLyrics := func(synced bool, lang, secondLine string) model.Lyrics {
lines := []model.Line{
{Value: "This is"},
{Value: secondLine},
}
if synced {
lines[0].Start = &zero
lines[1].Start = &secondTs
}
lyrics := model.Lyrics{
Lang: lang,
Line: lines,
Synced: synced,
}
return lyrics
}
sortLyrics := func(lines model.LyricList) model.LyricList {
slices.SortFunc(lines, func(a, b model.Lyrics) int {
langDiff := cmp.Compare(a.Lang, b.Lang)
if langDiff != 0 {
return langDiff
}
return cmp.Compare(a.Line[1].Value, b.Line[1].Value)
})
return lines
}
compareLyrics := func(m metadata_old.Tags, expected model.LyricList) {
lyrics := model.LyricList{}
Expect(json.Unmarshal([]byte(m.Lyrics()), &lyrics)).To(BeNil())
Expect(sortLyrics(lyrics)).To(Equal(sortLyrics(expected)))
}
// Only run these tests if FFmpeg is available
FFmpegContext := XContext
if ffmpeg.New().IsAvailable() {
FFmpegContext = Context
}
FFmpegContext("Extract with FFmpeg", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.Scanner.Extractor = "ffmpeg"
})
DescribeTable("Lyrics test",
func(file string) {
path := "tests/fixtures/" + file
mds, err := metadata_old.Extract(path)
Expect(err).ToNot(HaveOccurred())
Expect(mds).To(HaveLen(1))
m := mds[path]
compareLyrics(m, model.LyricList{
makeLyrics(true, "eng", "English"),
makeLyrics(true, "xxx", "unspecified"),
})
},
Entry("Parses AIFF file", "test.aiff"),
Entry("Parses MP3 files", "test.mp3"),
// Disabled, because it fails in pipeline
// Entry("Parses WAV files", "test.wav"),
// FFMPEG behaves very weirdly for multivalued tags for non-ID3
// Specifically, they are separated by ";, which is indistinguishable
// from other fields
)
})
})

View file

@ -72,10 +72,6 @@ func checkFFmpegInstallation() {
_, err := f.CmdPath()
if err != nil {
log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err)
if conf.Server.Scanner.Extractor == "ffmpeg" {
log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib")
conf.Server.Scanner.Extractor = "taglib"
}
return
}
if !f.IsProbeAvailable() {

View file

@ -176,8 +176,6 @@ func (NoopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) {
return nil, errors.New("noop ffmpeg: extract image not supported")
}
func (NoopFFmpeg) Probe(context.Context, []string) (string, error) { return "", nil }
func (NoopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) {
return nil, errors.New("noop ffmpeg: probe not supported")
}

View file

@ -57,12 +57,6 @@ func (ff *MockFFmpeg) ConvertAnimatedImage(_ context.Context, reader io.Reader,
return io.NopCloser(bytes.NewReader(data)), nil
}
func (ff *MockFFmpeg) Probe(context.Context, []string) (string, error) {
if ff.Error != nil {
return "", ff.Error
}
return "", nil
}
func (ff *MockFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) {
if ff.Error != nil {
return nil, ff.Error