mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-06 10:18:43 +01:00
Most albums embed the same picture in every track, so clients requesting per-track cover art (mf- ids) download and cache the same image once per song. This change hashes the embedded picture at scan time (XXH3 of the bytes, the same digest the artwork pipeline uses) and stores it on media_file.embed_art_hash. ToAlbum stores the hash of the track it already picks as the album's embedded cover (EmbedArtPath) on album.embed_art_hash. A new MediaFile.HasOwnCoverArt replaces the scattered "HasCoverArt && EnableMediaFileCoverArt" checks: a track whose hash equals its album's now returns the disc/album artwork id from CoverArtID, so the Subsonic, Jellyfin and native responses point every matching song at one cached image. Serving a stale mf- URL follows the same rule, so what is advertised and what is served agree. Tracks with a different embedded picture (compilations, per-track art) keep their own ids. Rows scanned before this change have an empty hash and keep the previous behavior until a full scan; no config option is added. The album hash is hydrated onto tracks with one batched PK lookup per page, only when the page has hashed tracks. BestImageIndex is exported from core/artwork so the scanner hashes the same picture the resolver serves.
365 lines
12 KiB
Go
365 lines
12 KiB
Go
package metadata_test
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/metadata"
|
|
"github.com/navidrome/navidrome/utils"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Metadata", func() {
|
|
var (
|
|
filePath string
|
|
fileInfo os.FileInfo
|
|
props metadata.Info
|
|
md metadata.Metadata
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
// It is easier to have a real file to test the mod and birth times
|
|
filePath = utils.TempFileName("test", ".mp3")
|
|
f, _ := os.Create(filePath)
|
|
DeferCleanup(func() {
|
|
_ = f.Close()
|
|
_ = os.Remove(filePath)
|
|
})
|
|
|
|
fileInfo, _ = os.Stat(filePath)
|
|
props = metadata.Info{
|
|
AudioProperties: metadata.AudioProperties{
|
|
Duration: time.Minute * 3,
|
|
BitRate: 320,
|
|
},
|
|
HasPicture: true,
|
|
PictureHash: "0123456789abcdef",
|
|
FileInfo: testFileInfo{fileInfo},
|
|
}
|
|
})
|
|
|
|
Describe("Metadata", func() {
|
|
Describe("New", func() {
|
|
It("should create a new Metadata object with the correct properties", func() {
|
|
props.Tags = model.RawTags{
|
|
"©ART": {"First Artist", "Second Artist"},
|
|
"----:com.apple.iTunes:CATALOGNUMBER": {"1234"},
|
|
"tbpm": {"120.6"},
|
|
"WM/IsCompilation": {"1"},
|
|
}
|
|
md = metadata.New(filePath, props)
|
|
|
|
Expect(md.FilePath()).To(Equal(filePath))
|
|
Expect(md.ModTime()).To(Equal(fileInfo.ModTime()))
|
|
Expect(md.BirthTime()).To(BeTemporally("~", md.ModTime(), time.Second))
|
|
Expect(md.Size()).To(Equal(fileInfo.Size()))
|
|
Expect(md.Suffix()).To(Equal("mp3"))
|
|
Expect(md.AudioProperties()).To(Equal(props.AudioProperties))
|
|
Expect(md.Length()).To(Equal(float32(3 * 60)))
|
|
Expect(md.HasPicture()).To(Equal(props.HasPicture))
|
|
Expect(md.PictureHash()).To(Equal(props.PictureHash))
|
|
Expect(md.Strings(model.TagTrackArtist)).To(Equal([]string{"First Artist", "Second Artist"}))
|
|
Expect(md.String(model.TagTrackArtist)).To(Equal("First Artist"))
|
|
Expect(md.Int(model.TagCatalogNumber)).To(Equal(int64(1234)))
|
|
Expect(md.Float(model.TagBPM)).To(Equal(120.6))
|
|
Expect(md.Bool(model.TagCompilation)).To(BeTrue())
|
|
Expect(md.All()).To(SatisfyAll(
|
|
HaveLen(4),
|
|
HaveKeyWithValue(model.TagTrackArtist, []string{"First Artist", "Second Artist"}),
|
|
HaveKeyWithValue(model.TagBPM, []string{"120.6"}),
|
|
HaveKeyWithValue(model.TagCompilation, []string{"1"}),
|
|
HaveKeyWithValue(model.TagCatalogNumber, []string{"1234"}),
|
|
))
|
|
|
|
})
|
|
|
|
It("should clean the tags map correctly", func() {
|
|
const unknownTag = "UNKNOWN_TAG"
|
|
props.Tags = model.RawTags{
|
|
"TPE1": {"Artist Name", "Artist Name", ""},
|
|
"©ART": {"Second Artist"},
|
|
"CatalogNumber": {""},
|
|
"Album": {"Album Name", "", "Album Name"},
|
|
"Date": {"2022-10-02 12:15:01"},
|
|
"Year": {"2022", "2022", ""},
|
|
"Genre": {"Pop", "", "Pop", "Rock"},
|
|
"Track": {"1/10", "1/10", ""},
|
|
unknownTag: {"value"},
|
|
}
|
|
md = metadata.New(filePath, props)
|
|
|
|
Expect(md.All()).To(SatisfyAll(
|
|
Not(HaveKey(unknownTag)),
|
|
HaveKeyWithValue(model.TagTrackArtist, []string{"Artist Name", "Second Artist"}),
|
|
HaveKeyWithValue(model.TagAlbum, []string{"Album Name"}),
|
|
HaveKeyWithValue(model.TagRecordingDate, []string{"2022-10-02"}),
|
|
HaveKeyWithValue(model.TagReleaseDate, []string{"2022"}),
|
|
HaveKeyWithValue(model.TagGenre, []string{"Pop", "Rock"}),
|
|
HaveKeyWithValue(model.TagTrackNumber, []string{"1/10"}),
|
|
HaveLen(6),
|
|
))
|
|
})
|
|
|
|
It("should truncate long strings", func() {
|
|
props.Tags = model.RawTags{
|
|
"Title": {strings.Repeat("a", 2048)},
|
|
"Comment": {strings.Repeat("a", 8192)},
|
|
"lyrics:xxx": {strings.Repeat("a", 2_000_000)},
|
|
}
|
|
md = metadata.New(filePath, props)
|
|
|
|
Expect(md.String(model.TagTitle)).To(HaveLen(1024))
|
|
Expect(md.String(model.TagComment)).To(HaveLen(4096))
|
|
pair := md.Pairs(model.TagLyrics)
|
|
|
|
Expect(pair).To(HaveLen(1))
|
|
Expect(pair[0].Key()).To(Equal("xxx"))
|
|
|
|
// Lyrics keep a much larger cap so word-timed karaoke survives.
|
|
// Note: a total of 6 characters are lost from maxLength from
|
|
// the key portion and separator.
|
|
Expect(pair[0].Value()).To(HaveLen(1048570))
|
|
})
|
|
|
|
It("should not split a multi-byte character when truncating", func() {
|
|
// 1024 is not a multiple of 3, so a byte-wise cut lands mid-rune.
|
|
props.Tags = model.RawTags{
|
|
"Title": {strings.Repeat("日", 2048)},
|
|
}
|
|
md = metadata.New(filePath, props)
|
|
|
|
title := md.String(model.TagTitle)
|
|
Expect(utf8.ValidString(title)).To(BeTrue(), "truncation produced invalid UTF-8")
|
|
Expect(len(title)).To(BeNumerically("<=", 1024))
|
|
})
|
|
|
|
It("should keep invalid bytes that are not at the truncation point", func() {
|
|
props.Tags = model.RawTags{
|
|
"Title": {"a\xffb" + strings.Repeat("c", 2048)},
|
|
}
|
|
md = metadata.New(filePath, props)
|
|
|
|
Expect(md.String(model.TagTitle)).To(HaveLen(1024))
|
|
})
|
|
|
|
DescribeTable("should normalize UUID tags to their canonical form",
|
|
func(raw, expected string) {
|
|
props.Tags = model.RawTags{"musicbrainz_artistid": {raw}}
|
|
md = metadata.New(filePath, props)
|
|
|
|
Expect(md.String(model.TagMusicBrainzArtistID)).To(Equal(expected))
|
|
},
|
|
Entry("canonical", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"),
|
|
Entry("uppercase", "F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"),
|
|
Entry("braced", "{f81d4fae-7dec-11d0-a765-00a0c91e6bf6}", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"),
|
|
Entry("urn prefix", "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"),
|
|
Entry("quoted", `"f81d4fae-7dec-11d0-a765-00a0c91e6bf6"`, "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"),
|
|
Entry("no dashes", "f81d4fae7dec11d0a76500a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"),
|
|
Entry("not a uuid", "the beatles", ""),
|
|
)
|
|
|
|
It("should split multiple values", func() {
|
|
props.Tags = model.RawTags{
|
|
"Genre": {"Rock/Pop;;Punk"},
|
|
}
|
|
md = metadata.New(filePath, props)
|
|
|
|
Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"}))
|
|
})
|
|
|
|
// Regression test for https://github.com/navidrome/navidrome/issues/5065
|
|
//
|
|
// MP3s with both an ID3v2 TMOO frame and a TXXX:MOOD frame are surfaced by
|
|
// TagLib's PropertyMap as a single "mood" key with multiple values. The split
|
|
// configuration must still apply to each value individually.
|
|
It("should split values from multiple frames mapping to the same tag", func() {
|
|
props.Tags = model.RawTags{
|
|
// Same shape as the bug report: two frames, comma-separated content.
|
|
"mood": {"Love, Emotional, Ballad", "Love; Emotional; Ballad"},
|
|
}
|
|
md = metadata.New(filePath, props)
|
|
|
|
Expect(md.Strings(model.TagMood)).To(ConsistOf("Love", "Emotional", "Ballad"))
|
|
})
|
|
})
|
|
|
|
DescribeTable("Date",
|
|
func(value string, expectedYear int, expectedDate string) {
|
|
props.Tags = model.RawTags{
|
|
"date": {value},
|
|
}
|
|
md = metadata.New(filePath, props)
|
|
|
|
testDate := md.Date(model.TagRecordingDate)
|
|
Expect(string(testDate)).To(Equal(expectedDate))
|
|
Expect(testDate.Year()).To(Equal(expectedYear))
|
|
},
|
|
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, "2016-12-31 12:15", 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, ""),
|
|
)
|
|
|
|
DescribeTable("NumAndTotal",
|
|
func(num, total string, expectedNum int, expectedTotal int) {
|
|
props.Tags = model.RawTags{
|
|
"Track": {num},
|
|
"TrackTotal": {total},
|
|
}
|
|
md = metadata.New(filePath, props)
|
|
|
|
testNum, testTotal := md.NumAndTotal(model.TagTrackNumber)
|
|
Expect(testNum).To(Equal(expectedNum))
|
|
Expect(testTotal).To(Equal(expectedTotal))
|
|
},
|
|
Entry(nil, "2", "", 2, 0),
|
|
Entry(nil, "2", "10", 2, 10),
|
|
Entry(nil, "2/10", "", 2, 10),
|
|
Entry(nil, "", "", 0, 0),
|
|
Entry(nil, "A", "", 0, 0),
|
|
)
|
|
|
|
Describe("Performers", func() {
|
|
Describe("ID3", func() {
|
|
BeforeEach(func() {
|
|
props.Tags = model.RawTags{
|
|
"PERFORMER:GUITAR": {"Guitarist 1", "Guitarist 2"},
|
|
"PERFORMER:BACKGROUND VOCALS": {"Backing Singer"},
|
|
"PERFORMER:PERFORMER": {"Wonderlove", "Lovewonder"},
|
|
}
|
|
})
|
|
|
|
It("should return the performers", func() {
|
|
md = metadata.New(filePath, props)
|
|
|
|
Expect(md.All()).To(HaveKey(model.TagPerformer))
|
|
Expect(md.Strings(model.TagPerformer)).To(ConsistOf(
|
|
metadata.NewPair("guitar", "Guitarist 1"),
|
|
metadata.NewPair("guitar", "Guitarist 2"),
|
|
metadata.NewPair("background vocals", "Backing Singer"),
|
|
metadata.NewPair("", "Wonderlove"),
|
|
metadata.NewPair("", "Lovewonder"),
|
|
))
|
|
})
|
|
})
|
|
|
|
Describe("Vorbis", func() {
|
|
BeforeEach(func() {
|
|
props.Tags = model.RawTags{
|
|
"PERFORMER": {
|
|
"John Adams (Rhodes piano)",
|
|
"Vincent Henry (alto saxophone, baritone saxophone and tenor saxophone)",
|
|
"Salaam Remi (drums (drum set) and organ)",
|
|
"Amy Winehouse (guitar)",
|
|
"Amy Winehouse (vocals)",
|
|
"Wonderlove",
|
|
},
|
|
}
|
|
})
|
|
|
|
It("should return the performers", func() {
|
|
md = metadata.New(filePath, props)
|
|
|
|
Expect(md.All()).To(HaveKey(model.TagPerformer))
|
|
Expect(md.Strings(model.TagPerformer)).To(ConsistOf(
|
|
metadata.NewPair("rhodes piano", "John Adams"),
|
|
metadata.NewPair("alto saxophone, baritone saxophone and tenor saxophone", "Vincent Henry"),
|
|
metadata.NewPair("drums (drum set) and organ", "Salaam Remi"),
|
|
metadata.NewPair("guitar", "Amy Winehouse"),
|
|
metadata.NewPair("vocals", "Amy Winehouse"),
|
|
metadata.NewPair("", "Wonderlove"),
|
|
))
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("Lyrics", func() {
|
|
BeforeEach(func() {
|
|
props.Tags = model.RawTags{
|
|
"LYRICS:POR": {"Letras"},
|
|
"LYRICS:ENG": {"Lyrics"},
|
|
}
|
|
})
|
|
|
|
It("should return the lyrics", func() {
|
|
md = metadata.New(filePath, props)
|
|
|
|
Expect(md.All()).To(HaveKey(model.TagLyrics))
|
|
Expect(md.Strings(model.TagLyrics)).To(ContainElements(
|
|
metadata.NewPair("por", "Letras"),
|
|
metadata.NewPair("eng", "Lyrics"),
|
|
))
|
|
})
|
|
|
|
It("should preserve lyrics starting with parentheses from alias tags", func() {
|
|
props.Tags = model.RawTags{
|
|
"LYRICS": {"(line one)\nline two\nline three"},
|
|
}
|
|
md = metadata.New(filePath, props)
|
|
|
|
Expect(md.All()).To(HaveKey(model.TagLyrics))
|
|
Expect(md.Strings(model.TagLyrics)).To(ContainElements(
|
|
metadata.NewPair("xxx", "(line one)\nline two\nline three"),
|
|
))
|
|
})
|
|
})
|
|
|
|
Describe("ReplayGain", func() {
|
|
createMF := func(tag, tagValue string) model.MediaFile {
|
|
props.Tags = model.RawTags{
|
|
tag: {tagValue},
|
|
}
|
|
md = metadata.New(filePath, props)
|
|
return md.ToMediaFile(0, "0")
|
|
}
|
|
|
|
DescribeTable("Gain",
|
|
func(tagValue string, expected *float64) {
|
|
mf := createMF("replaygain_track_gain", tagValue)
|
|
Expect(mf.RGTrackGain).To(Equal(expected))
|
|
},
|
|
Entry("0", "0", new(0.0)),
|
|
Entry("1.2dB", "1.2dB", new(1.2)),
|
|
Entry("Infinity", "Infinity", nil),
|
|
Entry("Invalid value", "INVALID VALUE", nil),
|
|
Entry("NaN", "NaN", nil),
|
|
)
|
|
DescribeTable("Peak",
|
|
func(tagValue string, expected *float64) {
|
|
mf := createMF("replaygain_track_peak", tagValue)
|
|
Expect(mf.RGTrackPeak).To(Equal(expected))
|
|
},
|
|
Entry("0", "0", new(0.0)),
|
|
Entry("1.0", "1.0", new(1.0)),
|
|
Entry("0.5", "0.5", new(0.5)),
|
|
Entry("Invalid dB suffix", "0.7dB", nil),
|
|
Entry("Infinity", "Infinity", nil),
|
|
Entry("Invalid value", "INVALID VALUE", nil),
|
|
Entry("NaN", "NaN", nil),
|
|
)
|
|
DescribeTable("getR128GainValue",
|
|
func(tagValue string, expected *float64) {
|
|
mf := createMF("r128_track_gain", tagValue)
|
|
Expect(mf.RGTrackGain).To(Equal(expected))
|
|
|
|
},
|
|
Entry("0", "0", new(5.0)),
|
|
Entry("-3776", "-3776", new(-9.75)),
|
|
Entry("Infinity", "Infinity", nil),
|
|
Entry("Invalid value", "INVALID VALUE", nil),
|
|
)
|
|
})
|
|
|
|
})
|
|
})
|