navidrome/server/jellyfin/dto/mappers_test.go
Deluan Quintão 5927e693d1
feat(jellyfin): filter items by year and record label (#5817)
* feat(persistence): add AlbumRepository.GetYears for distinct album years

* test(persistence): verify GetYears de-duplicates repeated years

Regression test that adds two albums with the same non-zero max_year
(2005) and verifies that GetYears() returns that year exactly once,
ensuring the SQL DISTINCT clause is applied correctly. Catches any
future removal of DISTINCT from the GetYears query.

* feat(jellyfin): add legacy /Items/Filters endpoint (genres + years)

* feat(jellyfin): add /Studios endpoint from record label tags

* refactor(persistence): drop duplicate columns in tagRepository.GetAll

* fix(jellyfin): exclude missing albums from filter years

GetYears only filtered max_year > 0, so albums whose files were all removed
(missing=true, kept when Scanner.PurgeMissing=never) contributed stale years
to /Items/Filters. Filter them out like the normal album listings do. Also
return an empty slice from the MockAlbumRepo to match the real repository.

* feat(jellyfin): filter /Items by Years=

* feat(jellyfin): filter /Items by StudioIds= (record labels)

* feat(jellyfin): scope filter and studio lists to ParentId library

* refactor(jellyfin): extract parentIDScope and libraryScopeFilter helpers

Collapse the three inline resolveLibraryScope(dto.DecodeID(parentid)) call
sites and the duplicated empty-scope guard into two small helpers, so the
empty-scope=unrestricted contract lives in one place. Reuse the existing
names() helper in the Years= e2e test.

* feat(jellyfin): expose record labels as album Studios

Add a Studios field to the album BaseItemDto, populated from the record-label
tags and gated behind Fields=Studios (matching Jellyfin's ItemFields
convention). Studio ids reuse the record-label tag identity, so they round-trip
with the /Studios list and the StudioIds= filter. Real Jellyfin leaves Studios
empty for music; Feishin reads it as the album's record label.
2026-07-19 12:39:31 -04:00

475 lines
19 KiB
Go

package dto
import (
"encoding/json"
"time"
"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: "song-1", Title: "Song", Album: "Alb", AlbumID: "alb-1",
Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1,
Year: 1999, Duration: 60, Size: 2_500_000,
Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}},
}
mf.PlayCount = 2
mf.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("song-1")))
Expect(item.AlbumId).To(Equal(EncodeID("alb-1")))
Expect(item.ParentId).To(Equal(EncodeID("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("song-1")))
Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1")))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag))
Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6))
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}}))
})
Describe("Fields gating (matches real Jellyfin)", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60,
SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`}
It("omits MediaSources and SortName when Fields does not ask for them", func() {
item := SongToBaseItem(mf, nil)
Expect(item.MediaSources).To(BeNil())
Expect(item.SortName).To(BeEmpty())
})
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)))
})
It("includes SortName (from the sort title) only when Fields=SortName", func() {
Expect(SongToBaseItem(mf, ParseFields("SortName")).SortName).To(Equal("sort song"))
})
It("sets HasLyrics from the media file's lyrics", func() {
Expect(SongToBaseItem(mf, nil).HasLyrics).To(BeTrue())
Expect(SongToBaseItem(model.MediaFile{ID: "s2", Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse())
// "[]" is the no-lyrics sentinel, not a truthy value.
Expect(SongToBaseItem(model.MediaFile{ID: "s3", Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse())
})
})
It("omits ImageBlurHashes when a song has no album", func() {
mf := model.MediaFile{ID: "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: "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:00Z"))
})
It("omits DateCreated when CreatedAt is the zero time", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "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: "s1", Title: "Song",
Artist: "The Band", ArtistID: "ar-1",
AlbumArtist: "Various", AlbumArtistID: "ar-2",
}
item := SongToBaseItem(mf, nil)
Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID("ar-1")}}))
Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID("ar-2")}}))
})
It("omits ArtistItems when the track has no artist id", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "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: "s1", Title: "Song"}, nil).Artists).To(BeNil())
})
It("splits Artists and ArtistItems per track artist from Participants", func() {
mf := model.MediaFile{
ID: "s1", Title: "Oooh",
Artist: "De La Soul feat. Redman", ArtistID: "ar-delasoul",
AlbumArtist: "De La Soul", AlbumArtistID: "ar-delasoul",
}
mf.Participants = model.Participants{
model.RoleArtist: model.ParticipantList{
{Artist: model.Artist{ID: "ar-delasoul", Name: "De La Soul"}},
{Artist: model.Artist{ID: "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("ar-delasoul")},
{Name: "Redman", Id: EncodeID("ar-redman")},
}))
// AlbumArtists stays single, matching real Jellyfin.
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)
Expect(src.Id).To(Equal(EncodeID("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: "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: "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: "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: "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: "s1", Lyrics: "[]"})
Expect(src.MediaStreams).To(HaveLen(1))
})
})
It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() {
mf := model.MediaFile{
ID: "song-2", Title: "Song", Album: "Alb", AlbumID: "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: "song-3", Title: "Song", Album: "Alb", AlbumID: "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(playDate.Format(time.RFC3339)))
})
It("maps an album to a MusicAlbum folder item", func() {
al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "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("alb-1")))
Expect(item.ParentId).To(Equal(EncodeID("art-1")))
Expect(item.AlbumArtists).To(HaveLen(1))
Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID("art-1")))
Expect(item.ArtistItems).To(Equal(item.AlbumArtists))
Expect(*item.ProductionYear).To(Equal(1999))
Expect(*item.ChildCount).To(Equal(10))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"]))
Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6))
Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"}))
Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}}))
})
It("populates album Studios from record-label tags only when Fields=Studios", func() {
al := model.Album{ID: "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: "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: "al1", Name: "Album"}, nil))
Expect(err).ToNot(HaveOccurred())
Expect(string(b)).ToNot(ContainSubstring("NormalizationGain"))
})
It("maps an artist to a MusicArtist folder item", func() {
ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20}
item := ArtistToBaseItem(ar)
Expect(item.Type).To(Equal("MusicArtist"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID("art-1")))
Expect(*item.AlbumCount).To(Equal(2))
})
It("maps a genre to a MusicGenre folder item", func() {
g := model.Genre{ID: "genre-1", Name: "Rock"}
item := GenreToBaseItem(g)
Expect(item.Type).To(Equal("MusicGenre"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID("genre-1")))
Expect(item.Name).To(Equal("Rock"))
})
It("maps a tag to a Studio BaseItemDto", func() {
item := StudioToBaseItem(model.Tag{ID: "t1", TagValue: "Blue Note"})
Expect(item.Type).To(Equal("Studio"))
Expect(item.Name).To(Equal("Blue Note"))
Expect(item.Id).To(Equal(EncodeID("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: "s1", Title: "Song", Date: "2007-02-01", Year: 2007}
item := SongToBaseItem(mf, nil)
Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00Z"))
})
It("pads a year-only date so clients can parse it", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007", Year: 2007}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00Z"))
})
It("pads a year-month date", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Date: "2007-02"}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00Z"))
})
It("falls back to the year when no date tag exists", func() {
mf := model.MediaFile{ID: "s1", Title: "Song", Year: 1999}
Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00Z"))
})
It("is omitted when the track has no date at all", func() {
Expect(SongToBaseItem(model.MediaFile{ID: "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: "a1", Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z"))
Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z"))
Expect(AlbumToBaseItem(model.Album{ID: "a3"}, nil).PremiereDate).To(BeNil())
})
})
It("maps a playlist to a Playlist BaseItemDto", func() {
p := model.Playlist{
ID: "pl-1", Name: "Chill", SongCount: 7, Duration: 120,
Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2},
}
item := PlaylistToBaseItem(p)
Expect(item.Type).To(Equal("Playlist"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.Id).To(Equal(EncodeID("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))
tag := item.ImageTags["Primary"]
Expect(tag).ToNot(BeEmpty())
Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(tag))
Expect(item.ImageBlurHashes["Primary"][tag]).To(HaveLen(6))
})
It("changes the playlist image tag and blurhash when the playlist is updated (cover upload)", func() {
p := model.Playlist{ID: "pl-1", Name: "Chill", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)}
before := PlaylistToBaseItem(p)
p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
after := PlaylistToBaseItem(p)
// Finamp caches covers keyed by blurHash, so tag and blurhash must change with the cover.
Expect(after.ImageTags["Primary"]).ToNot(Equal(before.ImageTags["Primary"]))
Expect(after.ImageBlurHashes["Primary"]).ToNot(Equal(before.ImageBlurHashes["Primary"]))
})
It("keeps the playlist image tag stable when nothing changed", func() {
p := model.Playlist{ID: "pl-1", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)}
Expect(PlaylistToBaseItem(p).ImageTags).To(Equal(PlaylistToBaseItem(p).ImageTags))
})
})
var _ = Describe("LyricDtoFromLyrics", func() {
ms := func(v int64) *int64 { return &v }
mf := model.MediaFile{ID: "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)))
})
})