mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(jellyfin): expose NormalizationGain from ReplayGain tags (#5815)
* feat(jellyfin): expose NormalizationGain from ReplayGain tags Adds NormalizationGain and AlbumNormalizationGain to Audio BaseItemDtos, sourced from the scanner's ReplayGain values (REPLAYGAIN_* tags, with R128_* already converted to the same -18 LUFS reference). Same wire contract as real Jellyfin: PascalCase keys, omitted when absent, no Fields gating. Album items intentionally omit the field: model.Album has no gain column and Feishin reads gain from song DTOs. * test(jellyfin): e2e coverage for NormalizationGain fields * test(jellyfin): drop redundant NormalizationGain passthrough spec The JSON-casing spec already proves the mapped values (the substring "NormalizationGain":-3.5 can only appear if the passthrough worked), so the direct-struct spec added no coverage. * style(jellyfin): use ASCII punctuation in gain comment
This commit is contained in:
parent
59f1b4206c
commit
7234ea23b7
@ -61,20 +61,22 @@ type BaseItemDto struct {
|
||||
PremiereDate *string `json:"PremiereDate,omitempty"`
|
||||
// DateCreated is the ISO 8601 date the item was added to the library; clients show it as
|
||||
// "Date Added" and sort "Recently Added" by it.
|
||||
DateCreated string `json:"DateCreated,omitempty"`
|
||||
Album string `json:"Album,omitempty"`
|
||||
AlbumId string `json:"AlbumId,omitempty"`
|
||||
AlbumArtist string `json:"AlbumArtist,omitempty"`
|
||||
AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"`
|
||||
AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"`
|
||||
Artists []string `json:"Artists,omitempty"`
|
||||
ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"`
|
||||
Genres []string `json:"Genres,omitempty"`
|
||||
GenreItems []NameGuidPair `json:"GenreItems,omitempty"`
|
||||
ChildCount *int `json:"ChildCount,omitempty"`
|
||||
SongCount *int `json:"SongCount,omitempty"`
|
||||
AlbumCount *int `json:"AlbumCount,omitempty"`
|
||||
ImageTags map[string]string `json:"ImageTags,omitempty"`
|
||||
DateCreated string `json:"DateCreated,omitempty"`
|
||||
Album string `json:"Album,omitempty"`
|
||||
AlbumId string `json:"AlbumId,omitempty"`
|
||||
AlbumArtist string `json:"AlbumArtist,omitempty"`
|
||||
AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"`
|
||||
AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"`
|
||||
Artists []string `json:"Artists,omitempty"`
|
||||
ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"`
|
||||
Genres []string `json:"Genres,omitempty"`
|
||||
GenreItems []NameGuidPair `json:"GenreItems,omitempty"`
|
||||
NormalizationGain *float64 `json:"NormalizationGain,omitempty"`
|
||||
AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"`
|
||||
ChildCount *int `json:"ChildCount,omitempty"`
|
||||
SongCount *int `json:"SongCount,omitempty"`
|
||||
AlbumCount *int `json:"AlbumCount,omitempty"`
|
||||
ImageTags map[string]string `json:"ImageTags,omitempty"`
|
||||
// ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a
|
||||
// de-dup key for image downloads (and a placeholder); absent, it warns the server isn't
|
||||
// calculating blurhashes.
|
||||
|
||||
@ -171,6 +171,9 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
|
||||
if mf.AlbumArtistID != "" {
|
||||
item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}}
|
||||
}
|
||||
// dB to apply at the RG2 -18 LUFS reference, same convention real Jellyfin uses; no conversion.
|
||||
item.NormalizationGain = mf.RGTrackGain
|
||||
item.AlbumNormalizationGain = mf.RGAlbumGain
|
||||
if mf.Year > 0 {
|
||||
item.ProductionYear = new(mf.Year)
|
||||
}
|
||||
|
||||
@ -125,6 +125,22 @@ var _ = Describe("mappers", func() {
|
||||
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}}))
|
||||
})
|
||||
|
||||
It("serializes normalization gains with Jellyfin's exact key casing", func() {
|
||||
mf := model.MediaFile{ID: "s1", Title: "Song",
|
||||
RGTrackGain: new(-3.5), RGAlbumGain: new(-4.25)}
|
||||
b, err := json.Marshal(SongToBaseItem(mf, nil))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-3.5`))
|
||||
Expect(string(b)).To(ContainSubstring(`"AlbumNormalizationGain":-4.25`))
|
||||
})
|
||||
|
||||
It("omits normalization gains when the file has no ReplayGain tags", func() {
|
||||
b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
// Substring check covers both keys (AlbumNormalizationGain contains NormalizationGain).
|
||||
Expect(string(b)).ToNot(ContainSubstring("NormalizationGain"))
|
||||
})
|
||||
|
||||
It("builds a MediaSourceInfo from a media file", func() {
|
||||
mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
|
||||
src := MediaSourceFromMediaFile(mf)
|
||||
|
||||
@ -415,6 +415,22 @@ var _ = Describe("Browsing", func() {
|
||||
Expect(item.AlbumArtists[0].Name).To(Equal("Miles Davis"))
|
||||
})
|
||||
|
||||
It("exposes NormalizationGain and AlbumNormalizationGain from ReplayGain tags", func() {
|
||||
var item dto.BaseItemDto
|
||||
parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &item)
|
||||
Expect(item.NormalizationGain).ToNot(BeNil())
|
||||
Expect(*item.NormalizationGain).To(BeNumerically("~", -3.5, 0.001))
|
||||
Expect(item.AlbumNormalizationGain).ToNot(BeNil())
|
||||
Expect(*item.AlbumNormalizationGain).To(BeNumerically("~", -4.25, 0.001))
|
||||
})
|
||||
|
||||
It("omits normalization gains for files without ReplayGain tags", func() {
|
||||
var item dto.BaseItemDto
|
||||
parseInto(get("/Items/"+enc(songID("So What"))), &item)
|
||||
Expect(item.NormalizationGain).To(BeNil())
|
||||
Expect(item.AlbumNormalizationGain).To(BeNil())
|
||||
})
|
||||
|
||||
It("resolves an artist", func() {
|
||||
var item dto.BaseItemDto
|
||||
parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item)
|
||||
|
||||
@ -121,7 +121,9 @@ func buildTestFS() storagetest.FakeFS {
|
||||
"Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")),
|
||||
"Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")),
|
||||
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven", _t{
|
||||
"lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold",
|
||||
"lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold",
|
||||
"replaygain_track_gain": "-3.50 dB",
|
||||
"replaygain_album_gain": "-4.25 dB",
|
||||
})),
|
||||
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")),
|
||||
"Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user