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:
Deluan Quintão 2026-07-18 20:26:14 -04:00 committed by GitHub
parent 59f1b4206c
commit 7234ea23b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 54 additions and 15 deletions

View File

@ -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.

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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")),