mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-19 00:45:59 +00:00
Some checks failed
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Validate DB migrations (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go plugins (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Report coverage (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-1 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-2 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-3 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-4 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-5 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-6 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-7 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-8 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-9 (push) Has been cancelled
Pipeline: Test, Lint, Build / Build-10 (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
* fix(jellyfin): honor likes on /Rating and add POST UserData endpoints
POST .../Rating only read a numeric `rating` param that upstream Jellyfin
does not define, so a request using Jellyfin's `likes` flag (or no value
at all, or a malformed one) silently cleared the user's rating. The
handler now reads `likes` like Jellyfin (true stores 5 stars, false 1
star, matching Jellyfin's 10 and 1), keeps the numeric `rating` param for
existing clients, and answers 400 without writing when the value is
missing, malformed or outside 0-10. Clearing is left to DELETE. The
current /UserItems/{itemId}/Rating spelling is now routed as well.
Adds POST /UserItems/{itemId}/UserData and the legacy
/Users/{userId}/Items/{itemId}/UserData. They write Rating, Likes and
IsFavorite with Jellyfin's precedence (Rating wins over Likes), leave
omitted or null fields untouched, and ignore play-state fields. The body
must be a single JSON object: trailing data, null, or out-of-range values
are rejected before anything is written.
Rating and UserData writes whose userId names a different user now get
403 instead of silently writing the caller's own data. Annotation
responses return the item's full UserData, including Jellyfin's derived
Likes flag (rating >= 6.5). The README compatibility notes are updated.
* refactor(jellyfin): simplify rating and UserData handlers
Moves the Jellyfin 0-10 to stars conversions (StarsFromRating,
StarsFromLikes) into the dto package next to the reverse mapping and the
Likes threshold, so both directions live together. The userId ownership
check is folded into a single applyCallerUserData helper instead of being
called by hand in three handlers, the setFavorite passthrough is dropped,
and decodeUserDataBody now writes its own 400 like the other (value, ok)
helpers.
Tests: withChiURLParam is now variadic, replacing the duplicate
withChiParams helper; the three other-user 403 specs and the
empty-object/no-event spec become table entries; and redundant specs are
removed. Behavior is unchanged.
792 lines
33 KiB
Go
792 lines
33 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())
|
|
})
|
|
|
|
DescribeTable("UserData derives Likes from the rating like Jellyfin (rating >= 6.5)",
|
|
func(stars int, expected *bool) {
|
|
d := UserData(model.Annotations{Rating: stars}, testID("s1"))
|
|
Expect(d.Likes).To(Equal(expected))
|
|
},
|
|
Entry("unrated has no Likes", 0, nil),
|
|
Entry("1 star (Jellyfin 2) dislikes", 1, new(false)),
|
|
Entry("3 stars (Jellyfin 6) dislikes", 3, new(false)),
|
|
Entry("4 stars (Jellyfin 8) likes", 4, new(true)),
|
|
Entry("5 stars (Jellyfin 10) likes", 5, new(true)),
|
|
)
|
|
|
|
It("emits Rating as a whole number and omits Likes when unrated", func() {
|
|
rated, err := json.Marshal(UserData(model.Annotations{Rating: 4}, testID("s1")))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(rated)).To(ContainSubstring(`"Rating":8,`))
|
|
Expect(string(rated)).To(ContainSubstring(`"Likes":true`))
|
|
|
|
unrated, err := json.Marshal(UserData(model.Annotations{}, testID("s1")))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(unrated)).ToNot(ContainSubstring(`"Rating"`))
|
|
Expect(string(unrated)).ToNot(ContainSubstring(`"Likes"`))
|
|
})
|
|
|
|
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)))
|
|
})
|
|
})
|