navidrome/server/jellyfin/dto/mappers_test.go
Deluan Quintão b76ae14286
feat(jellyfin): add Quick Connect sign-in (#6174)
* feat(jellyfin): add Quick Connect sign-in

Jellyfin clients can now sign in without a password: the client shows a
6-digit code, a signed-in user approves it, and the client redeems a secret
for its access token.

- core/quickconnect: in-memory store shared by both routers through wire.
  Codes expire after 10 minutes; a secret redeems only once (Jellyfin
  allows repeats for 10 minutes); at most 1000 pending requests.
- Jellyfin API: Initiate, Connect, Authorize and AuthenticateWithQuickConnect.
  Admins may approve for another user via UserId, like Swiftfin's admin page.
  Initiate and redeem share the login rate limiter; Connect does not, since
  Finamp and Streamyfin poll it every second.
- Web UI: a Quick Connect item in the user menu looks up the code and shows
  the app and device before approving, so a user can't be tricked into
  approving an unknown device blindly.
- Jellyfin.QuickConnect option, on by default like Jellyfin. It only matters
  when the Jellyfin API is enabled.

* refactor(jellyfin): tidy Quick Connect naming and route guards

Group the Quick Connect routes under one requireQuickConnect guard, make the
request's device a named field so req.Device.ID can't be mistaken for a
request id, and rename the web API response type to quickConnectDevice.

* refactor(jellyfin): remove duplicated Jellyfin date formatting function

* test(jellyfin): set play count and starred in the song fixture literal

* refactor(jellyfin): inline the Quick Connect redeem body and use the shared date helper

* fix(jellyfin): bound the client fields Quick Connect keeps in memory

Initiate is unauthenticated and keeps the Client, Device, DeviceId and Version
header fields for up to ten minutes. With no header size limit, each pending
request could hold about 1 MB, and even a short field kept the whole header
alive because the parsed values are substrings of it. Reject fields over 512
bytes and copy the stored values.

Also answer 500 instead of 401 when the redeem user lookup fails for a reason
other than the user being gone.

* fix(jellyfin): rate-limit Quick Connect code approval

Any signed-in user could try codes without limit on the Jellyfin Authorize
endpoint and the web UI lookup/authorize endpoints, and so could approve
another person's pending device for their own account. Apply the same per-IP
limiter as the login (AuthRequestLimit/AuthWindowLength) to both surfaces.
2026-09-19 14:57:01 -04:00

769 lines
32 KiB
Go

package dto
import (
"encoding/json"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("mappers", func() {
It("maps a song to an Audio BaseItemDto", func() {
mf := model.MediaFile{
ID: testID("song-1"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1,
Year: 1999, Duration: 60, Size: 2_500_000,
Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}},
PlayCount: 2,
Starred: true,
}
item := SongToBaseItem(mf, nil)
Expect(item.Type).To(Equal("Audio"))
Expect(item.MediaType).To(Equal("Audio"))
Expect(item.IsFolder).To(BeFalse())
Expect(item.LocationType).To(Equal("FileSystem"))
Expect(item.Id).To(Equal(EncodeID(testID("song-1"))))
Expect(item.AlbumId).To(Equal(EncodeID(testID("alb-1"))))
Expect(item.ParentId).To(Equal(EncodeID(testID("alb-1"))))
Expect(item.RunTimeTicks).To(Equal(int64(600_000_000)))
Expect(*item.IndexNumber).To(Equal(3))
Expect(item.UserData.IsFavorite).To(BeTrue())
Expect(item.UserData.PlayCount).To(Equal(2))
Expect(item.UserData.Played).To(BeTrue())
Expect(item.UserData.Key).To(Equal(EncodeID(testID("song-1"))))
Expect(item.UserData.ItemId).To(Equal(EncodeID(testID("song-1"))))
Expect(item.AlbumPrimaryImageTag).To(Equal(testID("alb-1")))
Expect(item.ImageBlurHashes).To(BeNil())
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}}))
})
Describe("Fields gating (matches real Jellyfin)", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60,
SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`}
It("omits MediaSources when Fields does not ask for them", func() {
item := SongToBaseItem(mf, nil)
Expect(item.MediaSources).To(BeNil())
})
It("includes MediaSources only when Fields=MediaSources", func() {
item := SongToBaseItem(mf, ParseFields("ChildCount,MediaSources,SortName"))
Expect(item.MediaSources).To(HaveLen(1))
Expect(item.MediaSources[0].Size).To(Equal(int64(2_500_000)))
})
// SortName must match the server sort order — see the sortName helper.
Describe("SortName", func() {
song := model.MediaFile{ID: testID("s1"), Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"}
ar := model.Artist{ID: testID("art-1"), Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"}
al := model.Album{ID: testID("alb-1"), Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"}
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("is omitted unless Fields=SortName", func() {
Expect(SongToBaseItem(song, nil).SortName).To(BeEmpty())
Expect(ArtistToBaseItem(ar, nil).SortName).To(BeEmpty())
Expect(AlbumToBaseItem(al, nil).SortName).To(BeEmpty())
})
It("uses the order names by default, ignoring sort tags", func() {
Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("song"))
Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("b-52's"))
Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("wall"))
})
Context("with PreferSortTags", func() {
BeforeEach(func() {
conf.Server.PreferSortTags = true
})
It("prefers the sort tags", func() {
Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("Song, The"))
Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("B-52's, The"))
Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("Wall, The"))
})
It("falls back to the order name when there is no sort tag", func() {
Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "The X", OrderArtistName: "x"},
ParseFields("SortName")).SortName).To(Equal("x"))
})
})
It("falls back to the display name when order name and sort tag are empty", func() {
Expect(SongToBaseItem(model.MediaFile{ID: testID("s"), Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T"))
Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N"))
Expect(AlbumToBaseItem(model.Album{ID: testID("al"), Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A"))
})
})
It("sets HasLyrics from the media file's lyrics", func() {
Expect(*SongToBaseItem(mf, nil).HasLyrics).To(BeTrue())
Expect(*SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse())
// "[]" is the no-lyrics sentinel, not a truthy value.
Expect(*SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse())
})
})
It("omits ImageBlurHashes when a song has no album", func() {
mf := model.MediaFile{ID: testID("song-noalbum"), Title: "Song", Duration: 60}
item := SongToBaseItem(mf, nil)
Expect(item.AlbumPrimaryImageTag).To(BeEmpty())
Expect(item.ImageBlurHashes).To(BeNil())
})
It("sets DateCreated from the media file's CreatedAt", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)}
Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00.0000000Z"))
})
It("omits DateCreated when CreatedAt is the zero time", func() {
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).DateCreated).To(BeEmpty())
})
It("sets ArtistItems and AlbumArtists (encoded ids) from the track and album artist", func() {
mf := model.MediaFile{
ID: testID("s1"), Title: "Song",
Artist: "The Band", ArtistID: testID("ar-1"),
AlbumArtist: "Various", AlbumArtistID: testID("ar-2"),
}
item := SongToBaseItem(mf, nil)
Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID(testID("ar-1"))}}))
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID(testID("ar-2"))}}))
})
It("omits ArtistItems when the track has no artist id", func() {
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil())
})
It("omits Artists when the track has no artist name or participants", func() {
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).Artists).To(BeNil())
})
It("splits Artists and ArtistItems per track artist from Participants", func() {
mf := model.MediaFile{
ID: testID("s1"), Title: "Oooh",
Artist: "De La Soul feat. Redman", ArtistID: testID("ar-delasoul"),
AlbumArtist: "De La Soul", AlbumArtistID: testID("ar-delasoul"),
}
mf.Participants = model.Participants{
model.RoleArtist: model.ParticipantList{
{Artist: model.Artist{ID: testID("ar-delasoul"), Name: "De La Soul"}},
{Artist: model.Artist{ID: testID("ar-redman"), Name: "Redman"}},
},
}
item := SongToBaseItem(mf, nil)
Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"}))
Expect(item.ArtistItems).To(Equal([]NameGuidPair{
{Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))},
{Name: "Redman", Id: EncodeID(testID("ar-redman"))},
}))
// AlbumArtists stays single, matching real Jellyfin.
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))}}))
})
It("serializes normalization gains with Jellyfin's exact key casing", func() {
mf := model.MediaFile{ID: testID("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: testID("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: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
src := MediaSourceFromMediaFile(mf)
Expect(src.Id).To(Equal(EncodeID(testID("s1"))))
Expect(src.Size).To(Equal(int64(5242880)))
Expect(src.Container).To(Equal("mp3"))
Expect(src.Bitrate).To(Equal(320_000))
Expect(src.RunTimeTicks).To(Equal(int64(1_000_000_000)))
Expect(src.Protocol).To(Equal("Http"))
Expect(src.SupportsDirectPlay).To(BeTrue())
})
It("populates MediaStreams with a single Audio stream so Finamp can size downloads", func() {
mf := model.MediaFile{
ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100,
Channels: 2, SampleRate: 44100, Codec: "mp3",
}
src := MediaSourceFromMediaFile(mf)
Expect(src.MediaStreams).To(HaveLen(1))
stream := src.MediaStreams[0]
Expect(stream.Type).To(Equal("Audio"))
Expect(stream.Channels).To(Equal(2))
Expect(stream.SampleRate).To(Equal(44100))
Expect(stream.BitRate).To(Equal(320_000))
Expect(stream.Codec).To(Equal("mp3"))
Expect(stream.ChannelLayout).To(Equal("stereo"))
})
It("serializes all Finamp-required MediaSourceInfo bools and arrays, never as null", func() {
mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100}
src := MediaSourceFromMediaFile(mf)
b, err := json.Marshal(src)
Expect(err).ToNot(HaveOccurred())
j := string(b)
Expect(j).To(ContainSubstring(`"SupportsProbing":true`))
Expect(j).To(ContainSubstring(`"IsInfiniteStream":false`))
Expect(j).To(ContainSubstring(`"RequiresOpening":false`))
Expect(j).To(ContainSubstring(`"MediaAttachments":[]`))
Expect(j).To(ContainSubstring(`"Formats":[]`))
})
It("serializes MediaStream's required non-nullable bools, never omitted", func() {
stream := MediaStream{Type: "Audio", Index: 0}
b, err := json.Marshal(stream)
Expect(err).ToNot(HaveOccurred())
j := string(b)
Expect(j).To(ContainSubstring(`"Type":"Audio"`))
Expect(j).To(ContainSubstring(`"IsDefault":false`))
Expect(j).To(ContainSubstring(`"IsInterlaced":false`))
Expect(j).To(ContainSubstring(`"IsForced":false`))
Expect(j).To(ContainSubstring(`"IsExternal":false`))
Expect(j).To(ContainSubstring(`"IsTextSubtitleStream":false`))
Expect(j).To(ContainSubstring(`"SupportsExternalStream":false`))
})
Describe("Lyric media stream advertising", func() {
It("adds a Lyric media stream when the file has embedded lyrics", func() {
mf := model.MediaFile{ID: testID("s1"), Lyrics: `[{"line":[{"value":"la"}]}]`}
src := MediaSourceFromMediaFile(mf)
Expect(src.MediaStreams).To(HaveLen(2))
Expect(src.MediaStreams[0].Type).To(Equal("Audio"))
Expect(src.MediaStreams[1].Type).To(Equal("Lyric"))
Expect(src.MediaStreams[1].Index).To(Equal(1))
Expect(src.MediaStreams[1].IsExternal).To(BeTrue())
})
It("emits only the Audio stream without lyrics", func() {
src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1")})
Expect(src.MediaStreams).To(HaveLen(1))
Expect(src.MediaStreams[0].Type).To(Equal("Audio"))
})
It("emits only the Audio stream for the post-scan empty-lyrics sentinel", func() {
src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1"), Lyrics: "[]"})
Expect(src.MediaStreams).To(HaveLen(1))
})
})
It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() {
mf := model.MediaFile{
ID: testID("song-2"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", TrackNumber: 0, DiscNumber: 0,
Duration: 60,
}
item := SongToBaseItem(mf, nil)
Expect(item.IndexNumber).To(BeNil())
Expect(item.ParentIndexNumber).To(BeNil())
})
It("maps PlayDate to UserData.LastPlayedDate", func() {
playDate := time.Date(2023, 5, 17, 12, 30, 0, 0, time.UTC)
mf := model.MediaFile{
ID: testID("song-3"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"),
Artist: "Art", AlbumArtist: "AA", Duration: 60,
}
mf.PlayDate = &playDate
item := SongToBaseItem(mf, nil)
Expect(item.UserData.LastPlayedDate).NotTo(BeNil())
Expect(*item.UserData.LastPlayedDate).To(Equal("2023-05-17T12:30:00.0000000Z"))
})
It("maps an album to a MusicAlbum folder item", func() {
al := model.Album{ID: testID("alb-1"), Name: "Alb", AlbumArtist: "AA", AlbumArtistID: testID("art-1"), MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}}
item := AlbumToBaseItem(al, nil)
Expect(item.Type).To(Equal("MusicAlbum"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID(testID("alb-1"))))
Expect(item.ParentId).To(Equal(EncodeID(testID("art-1"))))
Expect(item.AlbumArtists).To(HaveLen(1))
Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID(testID("art-1"))))
Expect(item.ArtistItems).To(Equal(item.AlbumArtists))
Expect(*item.ProductionYear).To(Equal(1999))
Expect(*item.ChildCount).To(Equal(10))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-1")))
Expect(item.ImageBlurHashes).To(BeNil())
// Jellyfin sends both on every album; Manet stops its sync on an album without them.
Expect(item.Artists).To(Equal([]string{"AA"}))
Expect(item.LocationType).To(Equal("FileSystem"))
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}}))
})
It("populates album Studios from record-label tags only when Fields=Studios", func() {
al := model.Album{ID: testID("alb-2"), Name: "Alb2"}
al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}}
Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty())
item := AlbumToBaseItem(al, ParseFields("Studios"))
Expect(item.Studios).To(Equal([]NameGuidPair{
{Name: "Columbia", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Columbia").ID)},
{Name: "Legacy", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Legacy").ID)},
}))
})
It("sets NormalizationGain on the album from its ReplayGain", func() {
al := model.Album{ID: testID("al1"), Name: "Album", RGAlbumGain: new(-6.0)}
b, err := json.Marshal(AlbumToBaseItem(al, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`))
// Real Jellyfin never sets AlbumNormalizationGain on an album item.
Expect(string(b)).ToNot(ContainSubstring("AlbumNormalizationGain"))
})
It("omits NormalizationGain when the album has no ReplayGain", func() {
b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: testID("al1"), Name: "Album"}, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).ToNot(ContainSubstring("NormalizationGain"))
})
Describe("PrimaryImageAspectRatio", func() {
nonSquare := func() model.Album {
al := model.Album{ID: testID("al1"), Name: "Album"}
al.ImageHash, al.ImageWidth, al.ImageHeight = "abc", 1200, 800
return al
}
It("is omitted unless the request asks for it", func() {
Expect(AlbumToBaseItem(nonSquare(), nil).PrimaryImageAspectRatio).To(BeNil())
b, err := json.Marshal(AlbumToBaseItem(nonSquare(), nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).ToNot(ContainSubstring("PrimaryImageAspectRatio"))
})
It("carries the real ratio when asked", func() {
item := AlbumToBaseItem(nonSquare(), ParseFields("PrimaryImageAspectRatio"))
Expect(*item.PrimaryImageAspectRatio).To(BeNumerically("~", 1.5, 0.0001))
})
It("is omitted when the dimensions are unknown, rather than guessing square", func() {
al := model.Album{ID: testID("al1"), Name: "Album"}
al.ImageHash = "abc"
item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio"))
Expect(item.PrimaryImageAspectRatio).To(BeNil())
})
It("is omitted when the item has no image at all", func() {
al := model.Album{ID: testID("al1"), Name: "Album"}
al.ImageAbsent = true
al.ImageWidth, al.ImageHeight = 1200, 800
item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio"))
Expect(item.PrimaryImageAspectRatio).To(BeNil())
})
It("carries the ratio for an artist", func() {
ar := model.Artist{ID: testID("ar1"), Name: "Artist"}
ar.ImageHash, ar.ImageWidth, ar.ImageHeight = "abc", 1000, 500
Expect(*ArtistToBaseItem(ar, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio).
To(BeNumerically("~", 2.0, 0.0001))
})
It("carries the ratio for a playlist", func() {
pl := model.Playlist{ID: testID("pl1"), Name: "Playlist"}
pl.ImageHash, pl.ImageWidth, pl.ImageHeight = "abc", 400, 800
Expect(*PlaylistToBaseItem(pl, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio).
To(BeNumerically("~", 0.5, 0.0001))
})
It("carries the ratio for a song with its own art", func() {
mf := model.MediaFile{ID: testID("mf1"), Title: "Song"}
mf.ImageHash, mf.ImageWidth, mf.ImageHeight = "abc", 300, 600
Expect(*SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio).
To(BeNumerically("~", 0.5, 0.0001))
})
// A track without its own art shows the album's, so the ratio has to describe that image.
It("uses the album's dimensions for a track falling back to album art", func() {
mf := model.MediaFile{ID: testID("mf1"), Title: "Song", AlbumID: testID("al1")}
mf.AlbumImage.ImageHash, mf.AlbumImage.ImageWidth, mf.AlbumImage.ImageHeight = "abc", 1200, 800
item := SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio"))
Expect(item.AlbumPrimaryImageTag).To(Equal("abc"))
Expect(*item.PrimaryImageAspectRatio).To(BeNumerically("~", 1.5, 0.0001))
})
})
It("maps an artist to a MusicArtist folder item", func() {
ar := model.Artist{ID: testID("art-1"), Name: "AA", AlbumCount: 2, SongCount: 20}
item := ArtistToBaseItem(ar, nil)
Expect(item.Type).To(Equal("MusicArtist"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID(testID("art-1"))))
Expect(*item.AlbumCount).To(Equal(2))
})
It("maps a genre to a MusicGenre folder item", func() {
g := model.Genre{ID: testID("genre-1"), Name: "Rock"}
item := GenreToBaseItem(g)
Expect(item.Type).To(Equal("MusicGenre"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID(testID("genre-1"))))
Expect(item.Name).To(Equal("Rock"))
})
It("maps a tag to a Studio BaseItemDto", func() {
item := StudioToBaseItem(model.Tag{ID: testID("t1"), TagValue: "Blue Note"})
Expect(item.Type).To(Equal("Studio"))
Expect(item.Name).To(Equal("Blue Note"))
Expect(item.Id).To(Equal(EncodeID(testID("t1"))))
})
Describe("premiereDate", func() {
// Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily.
It("serializes a full date", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02-01", Year: 2007}
item := SongToBaseItem(mf, nil)
Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z"))
})
It("pads a year-only date so clients can parse it", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007", Year: 2007}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00.0000000Z"))
})
It("pads a year-month date", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02"}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z"))
})
It("falls back to the year when no date tag exists", func() {
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Year: 1999}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00.0000000Z"))
})
It("is omitted when the track has no date at all", func() {
Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).PremiereDate).To(BeNil())
})
It("is set on albums from their date, falling back to MaxYear", func() {
Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00.0000000Z"))
Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00.0000000Z"))
Expect(AlbumToBaseItem(model.Album{ID: testID("a3")}, nil).PremiereDate).To(BeNil())
})
})
It("maps a playlist to a Playlist BaseItemDto", func() {
p := model.Playlist{
ID: testID("pl-1"), Name: "Chill", SongCount: 7, Duration: 120,
Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2},
}
item := PlaylistToBaseItem(p, nil)
Expect(item.Type).To(Equal("Playlist"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID(testID("pl-1"))))
Expect(item.Name).To(Equal("Chill"))
Expect(item.MediaType).To(Equal("Audio"))
Expect(*item.ChildCount).To(Equal(7))
Expect(item.RunTimeTicks).To(Equal(int64(1_200_000_000)))
Expect(item.UserData.IsFavorite).To(BeTrue())
Expect(item.UserData.PlayCount).To(Equal(2))
Expect(*item.UserData.Rating).To(Equal(8.0))
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("pl-1")))
Expect(item.ImageBlurHashes).To(BeNil())
})
It("emits DateCreated and SortName on playlists, like albums and artists", func() {
p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)}
Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z"))
Expect(PlaylistToBaseItem(p, nil).SortName).To(BeEmpty())
Expect(PlaylistToBaseItem(p, ParseFields("SortName")).SortName).To(Equal("Chill"))
})
It("changes the playlist image tag when the cover content changes", func() {
p := model.Playlist{ID: testID("pl-1"), Name: "Chill"}
p.ImageHash = "1111111111111111"
before := PlaylistToBaseItem(p, nil)
p.ImageHash = "2222222222222222"
after := PlaylistToBaseItem(p, nil)
Expect(before.ImageTags["Primary"]).To(Equal("1111111111111111"))
Expect(after.ImageTags["Primary"]).To(Equal("2222222222222222"))
})
It("keeps the playlist image tag stable across a metadata-only edit", func() {
p := model.Playlist{ID: testID("pl-1"), UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)}
p.ImageHash = "1111111111111111"
before := PlaylistToBaseItem(p, nil)
p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
after := PlaylistToBaseItem(p, nil)
Expect(after.ImageTags["Primary"]).To(Equal(before.ImageTags["Primary"]))
})
// Nothing enqueues media files, so a track's own art only resolves when a client requests it;
// advertising just the album image would leave that cover unreachable.
Describe("unresolved embedded art", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.EnableMediaFileCoverArt = true
})
It("advertises the track id so the client triggers the read-through", func() {
mf := model.MediaFile{ID: testID("mf-1"), AlbumID: testID("alb-1"), HasCoverArt: true}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("mf-1")))
Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send")
Expect(item.AlbumPrimaryImageTag).To(BeEmpty())
})
It("falls back to the album when the track has no art of its own", func() {
mf := model.MediaFile{ID: testID("mf-2"), AlbumID: testID("alb-1"), HasCoverArt: false}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(BeEmpty())
Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef"))
})
It("falls back to the album once the track's art is known absent", func() {
mf := model.MediaFile{ID: testID("mf-3"), AlbumID: testID("alb-1"), HasCoverArt: true}
mf.ItemImage.ImageAbsent = true
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(BeEmpty())
Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef"))
})
It("falls back to the album when per-track art is disabled", func() {
conf.Server.EnableMediaFileCoverArt = false
mf := model.MediaFile{ID: testID("mf-4"), AlbumID: testID("alb-1"), HasCoverArt: true}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(BeEmpty())
Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef"))
})
})
Describe("primary image tags", func() {
It("uses the content hash as the tag and emits the real blurhash", func() {
al := model.Album{ID: testID("alb-1"), Name: "Album"}
al.ImageHash = "0123456789abcdef"
al.BlurHash = "LEHV6nWB2yk8"
item := AlbumToBaseItem(al, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8"))
})
It("omits the blurhash entirely when none was computed", func() {
al := model.Album{ID: testID("alb-2"), Name: "Album"}
al.ImageHash = "0123456789abcdef"
item := AlbumToBaseItem(al, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef"))
Expect(item.ImageBlurHashes).To(BeNil(), "a synthesized blurhash pins stale covers in Finamp")
})
It("omits tags for known-absent artwork", func() {
al := model.Album{ID: testID("alb-3"), Name: "Album"}
al.ImageAbsent = true
item := AlbumToBaseItem(al, nil)
Expect(item.ImageTags).To(BeEmpty())
Expect(item.ImageBlurHashes).To(BeNil())
})
It("falls back to the entity id while artwork is still unresolved", func() {
item := AlbumToBaseItem(model.Album{ID: testID("alb-4"), Name: "Album"}, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-4")))
Expect(item.ImageBlurHashes).To(BeNil())
})
It("versions an artist's tag by content hash", func() {
ar := model.Artist{ID: testID("art-1"), Name: "Artist"}
ar.ImageHash = "fedcba9876543210"
ar.BlurHash = "L6PZfSi_.AyE"
item := ArtistToBaseItem(ar, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "fedcba9876543210"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("fedcba9876543210", "L6PZfSi_.AyE"))
})
})
Describe("song and playlist image tags", func() {
It("versions a song's album tag by the album's content hash", func() {
mf := model.MediaFile{ID: testID("song-1"), Title: "Song", AlbumID: testID("alb-1")}
mf.AlbumImage.ImageHash = "0123456789abcdef"
mf.AlbumImage.BlurHash = "LEHV6nWB2yk8"
item := SongToBaseItem(mf, nil)
Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8"))
})
It("never synthesizes a song blurhash when the album has none", func() {
mf := model.MediaFile{ID: testID("song-2"), Title: "Song", AlbumID: testID("alb-2")}
mf.AlbumImage.ImageHash = "0123456789abcdef"
item := SongToBaseItem(mf, nil)
Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef"))
Expect(item.ImageBlurHashes).To(BeNil())
})
It("omits a song's album tag when the album art is known absent", func() {
mf := model.MediaFile{ID: testID("song-3"), Title: "Song", AlbumID: testID("alb-3")}
mf.AlbumImage.ImageAbsent = true
item := SongToBaseItem(mf, nil)
Expect(item.AlbumPrimaryImageTag).To(BeEmpty())
Expect(item.ImageBlurHashes).To(BeNil())
})
It("versions a playlist tag by content hash instead of UpdatedAt", func() {
pl := model.Playlist{ID: testID("pl-1"), Name: "Playlist"}
pl.ImageHash = "abcdef0123456789"
item := PlaylistToBaseItem(pl, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "abcdef0123456789"))
})
})
Describe("per-song artwork", func() {
It("emits the track's own Primary tag when it has distinct art", func() {
mf := model.MediaFile{ID: testID("song-own"), Title: "Song", AlbumID: testID("alb-1")}
mf.ImageHash = "aaaaaaaaaaaaaaaa"
mf.BlurHash = "LTRACKblur"
mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb"
mf.AlbumImage.BlurHash = "LALBUMblur"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "aaaaaaaaaaaaaaaa"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1),
"exactly one Primary entry: Go sorts map keys, so a second entry could pair the wrong blurhash with imageId")
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("aaaaaaaaaaaaaaaa", "LTRACKblur"))
})
It("falls back to the album tag when the track has no distinct art", func() {
mf := model.MediaFile{ID: testID("song-inherit"), Title: "Song", AlbumID: testID("alb-1")}
mf.ImageHash = "bbbbbbbbbbbbbbbb"
mf.BlurHash = "LALBUMblur"
mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb"
mf.AlbumImage.BlurHash = "LALBUMblur"
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(BeEmpty(), "an inherited cover is the album's image, not the track's")
Expect(item.AlbumPrimaryImageTag).To(Equal("bbbbbbbbbbbbbbbb"))
Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("bbbbbbbbbbbbbbbb", "LALBUMblur"))
})
It("omits the track tag when its own art is known absent", func() {
mf := model.MediaFile{ID: testID("song-absent"), Title: "Song", AlbumID: testID("alb-1")}
mf.ImageAbsent = true
mf.AlbumImage.ImageAbsent = true
item := SongToBaseItem(mf, nil)
Expect(item.ImageTags).To(BeEmpty())
Expect(item.AlbumPrimaryImageTag).To(BeEmpty())
Expect(item.ImageBlurHashes).To(BeNil())
})
})
})
var _ = Describe("LyricDtoFromLyrics", func() {
ms := func(v int64) *int64 { return &v }
mf := model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "Artist", Album: "Album", Duration: 100}
It("maps synced lyrics with tick conversion", func() {
l := model.Lyrics{
DisplayArtist: "Display Artist",
DisplayTitle: "Display Title",
Synced: true,
Offset: ms(-150),
Line: []model.Line{
{Start: ms(1000), Value: "line one"},
{Start: ms(2500), Value: "line two"},
},
}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Metadata.Artist).To(Equal("Display Artist"))
Expect(d.Metadata.Title).To(Equal("Display Title"))
Expect(d.Metadata.Album).To(Equal("Album"))
Expect(d.Metadata.IsSynced).To(BeTrue())
Expect(*d.Metadata.Offset).To(Equal(int64(-1_500_000)))
Expect(d.Metadata.Length).To(Equal(TicksFromSeconds(100)))
Expect(d.Lyrics).To(HaveLen(2))
Expect(d.Lyrics[0].Text).To(Equal("line one"))
Expect(*d.Lyrics[0].Start).To(Equal(int64(10_000_000)))
Expect(*d.Lyrics[1].Start).To(Equal(int64(25_000_000)))
})
It("falls back to the media file's artist and title", func() {
d := LyricDtoFromLyrics(mf, model.Lyrics{Line: []model.Line{{Value: "x"}}})
Expect(d.Metadata.Artist).To(Equal("Artist"))
Expect(d.Metadata.Title).To(Equal("Song"))
})
It("drops start-less lines from synced lyrics", func() {
l := model.Lyrics{Synced: true, Line: []model.Line{
{Start: ms(0), Value: "kept"},
{Value: "dropped"},
}}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Lyrics).To(HaveLen(1))
Expect(d.Lyrics[0].Text).To(Equal("kept"))
})
It("emits no Start on unsynced lyrics even when lines have one", func() {
l := model.Lyrics{Synced: false, Line: []model.Line{{Start: ms(1000), Value: "plain"}}}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Lyrics).To(HaveLen(1))
Expect(d.Lyrics[0].Start).To(BeNil())
Expect(d.Metadata.IsSynced).To(BeFalse())
})
It("maps word cues", func() {
end := int64(1500)
l := model.Lyrics{Synced: true, Line: []model.Line{{
Start: ms(1000),
Value: "word cue",
Cue: []model.Cue{{Start: ms(1000), End: &end, Value: "word", ByteStart: 0, ByteEnd: 4}},
}}}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Lyrics[0].Cues).To(HaveLen(1))
c := d.Lyrics[0].Cues[0]
Expect(c.Position).To(Equal(0))
Expect(c.EndPosition).To(Equal(4))
Expect(c.Start).To(Equal(int64(10_000_000)))
Expect(*c.End).To(Equal(int64(15_000_000)))
})
It("skips a start-less cue while keeping its sibling", func() {
l := model.Lyrics{Synced: true, Line: []model.Line{{
Start: ms(1000),
Value: "word cue",
Cue: []model.Cue{
{Start: nil, Value: "dropped", ByteStart: 0, ByteEnd: 7},
{Start: ms(1000), Value: "kept", ByteStart: 8, ByteEnd: 12},
},
}}}
d := LyricDtoFromLyrics(mf, l)
Expect(d.Lyrics[0].Cues).To(HaveLen(1))
c := d.Lyrics[0].Cues[0]
Expect(c.Position).To(Equal(8))
Expect(c.EndPosition).To(Equal(12))
Expect(c.Start).To(Equal(int64(10_000_000)))
})
})