package dto
import (
"cmp"
"fmt"
"math"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils/slice"
)
func sortName(sortTag, orderName, displayName string) string {
if conf.Server.PreferSortTags {
return cmp.Or(sortTag, orderName, displayName)
}
return cmp.Or(orderName, displayName)
}
const ticksPerMillis = 10_000
func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1000 * ticksPerMillis) }
func TicksFromMillis(ms int64) int64 { return ms * ticksPerMillis }
func MillisFromTicks(ticks int64) int64 { return ticks / ticksPerMillis }
func premiereDate(date string, year int) *string {
d := date
switch len(d) {
case 4:
d += "-01-01"
case 7:
d += "-01"
case 10:
default:
if year <= 0 {
return nil
}
d = fmt.Sprintf("%04d-01-01", year)
}
parsed, err := time.Parse(time.DateOnly, d)
if err != nil {
return nil
}
s := JellyfinDate(&parsed)
return &s
}
const jellyfinDateLayout = "2006-01-02T15:04:05.0000000Z07:00"
func JellyfinDate(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.UTC().Format(jellyfinDateLayout)
}
func channelLayout(n int) string {
switch n {
case 1:
return "mono"
case 2:
return "stereo"
case 6:
return "5.1"
case 8:
return "7.1"
default:
return ""
}
}
func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo {
streams := make([]MediaStream, 1, 2)
streams[0] = MediaStream{
Type: "Audio",
Index: 0,
Codec: mf.Codec,
BitRate: mf.BitRate * 1000,
Channels: mf.Channels,
SampleRate: mf.SampleRate,
ChannelLayout: channelLayout(mf.Channels),
}
if mf.HasEmbeddedLyrics() {
streams = append(streams, MediaStream{Type: "Lyric", Index: 1, IsExternal: true})
}
return MediaSourceInfo{
Id: EncodeID(mf.ID),
Protocol: "Http",
Container: mf.Suffix,
Size: mf.Size,
Name: mf.Title,
Type: "Default",
RunTimeTicks: TicksFromSeconds(mf.Duration),
Bitrate: mf.BitRate * 1000,
SupportsDirectPlay: true,
SupportsDirectStream: true,
SupportsTranscoding: true,
IsRemote: false,
SupportsProbing: true,
MediaStreams: streams,
MediaAttachments: []any{},
Formats: []string{},
}
}
const minLikeRating = 6.5
func StarsFromRating(rating float64) (int, bool) {
if !(rating >= 0 && rating <= 10) {
return 0, false
}
stars := int(math.Round(rating / 2))
if rating > 0 {
stars = max(stars, 1)
}
return stars, true
}
func StarsFromLikes(likes bool) int {
if likes {
return 5
}
return 1
}
func UserData(a model.Annotations, itemID string) *UserItemDataDto {
encodedID := EncodeID(itemID)
d := &UserItemDataDto{
PlayCount: int(a.PlayCount),
IsFavorite: a.Starred,
Played: a.PlayCount > 0,
Key: encodedID,
ItemId: encodedID,
}
if a.Rating > 0 {
r := float64(a.Rating) * 2
d.Rating = &r
d.Likes = new(r >= minLikeRating)
}
if s := JellyfinDate(a.PlayDate); s != "" {
d.LastPlayedDate = &s
}
return d
}
func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
albumID := EncodeID(mf.AlbumID)
item := BaseItemDto{
Name: mf.Title,
Id: EncodeID(mf.ID),
Type: "Audio",
MediaType: "Audio",
IsFolder: false,
LocationType: "FileSystem",
HasLyrics: new(mf.HasEmbeddedLyrics()),
ParentId: albumID,
Album: mf.Album,
AlbumId: albumID,
AlbumArtist: mf.AlbumArtist,
RunTimeTicks: TicksFromSeconds(mf.Duration),
DateCreated: JellyfinDate(&mf.CreatedAt),
Container: mf.Suffix,
CanDownload: true,
BackdropImageTags: []string{},
UserData: UserData(mf.Annotations, mf.ID),
}
if fields.Has("MediaSources") {
item.MediaSources = []MediaSourceInfo{MediaSourceFromMediaFile(mf)}
}
if fields.Has("SortName") {
item.SortName = sortName(mf.SortTitle, mf.OrderTitle, mf.Title)
}
if artists := mf.Participants[model.RoleArtist]; len(artists) > 0 {
item.Artists = slice.Map(artists, func(p model.Participant) string { return p.Name })
item.ArtistItems = slice.Map(artists, func(p model.Participant) NameGuidPair {
return NameGuidPair{Name: p.Name, Id: EncodeID(p.ID)}
})
} else {
if mf.Artist != "" {
item.Artists = []string{mf.Artist}
}
if mf.ArtistID != "" {
item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}}
}
}
if mf.AlbumArtistID != "" {
item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}}
}
item.NormalizationGain = mf.RGTrackGain
item.AlbumNormalizationGain = mf.RGAlbumGain
if mf.Year > 0 {
item.ProductionYear = new(mf.Year)
}
item.PremiereDate = premiereDate(mf.Date, mf.Year)
if mf.TrackNumber > 0 {
item.IndexNumber = new(mf.TrackNumber)
}
if mf.DiscNumber > 0 {
item.ParentIndexNumber = new(mf.DiscNumber)
}
if len(mf.Genres) > 0 {
for _, g := range mf.Genres {
item.Genres = append(item.Genres, g.Name)
item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name})
}
} else if mf.Genre != "" {
item.Genres = []string{mf.Genre}
}
if mf.ImageHash != "" && mf.ImageHash != mf.AlbumImage.ImageHash {
tag, blurs, ratio := primaryImage(mf.ItemImage, mf.ID, fields)
item.ImageTags = map[string]string{"Primary": tag}
item.ImageBlurHashes = blurs
item.PrimaryImageAspectRatio = ratio
} else if embeddedArtPending(mf) {
item.ImageTags = map[string]string{"Primary": mf.ID}
} else if mf.AlbumID != "" {
if tag, blurs, ratio := primaryImage(mf.AlbumImage, mf.AlbumID, fields); tag != "" {
item.AlbumPrimaryImageTag = tag
item.ImageBlurHashes = blurs
item.PrimaryImageAspectRatio = ratio
}
}
return item
}
func embeddedArtPending(mf model.MediaFile) bool {
return mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt &&
mf.ImageHash == "" && !mf.ItemImage.ImageAbsent
}
func primaryImage(img model.ItemImage, fallback string, fields Fields) (tag string, blurs map[string]map[string]string, ratio *float64) {
if img.ImageAbsent {
return "", nil, nil
}
tag = cmp.Or(img.ImageHash, fallback)
if img.BlurHash != "" {
blurs = map[string]map[string]string{"Primary": {tag: img.BlurHash}}
}
if fields.Has("PrimaryImageAspectRatio") {
ratio = img.AspectRatio()
}
return tag, blurs, ratio
}
func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto {
tag, blurs, ratio := primaryImage(al.ItemImage, al.ID, fields)
item := BaseItemDto{
Name: al.Name,
Id: EncodeID(al.ID),
Type: "MusicAlbum",
IsFolder: true,
LocationType: "FileSystem",
ParentId: EncodeID(al.AlbumArtistID),
AlbumArtist: al.AlbumArtist,
Album: al.Name,
ChildCount: new(al.SongCount),
SongCount: new(al.SongCount),
RunTimeTicks: TicksFromSeconds(al.Duration),
DateCreated: JellyfinDate(&al.CreatedAt),
ImageBlurHashes: blurs,
PrimaryImageAspectRatio: ratio,
BackdropImageTags: []string{},
UserData: UserData(al.Annotations, al.ID),
}
if tag != "" {
item.ImageTags = map[string]string{"Primary": tag}
}
item.Artists = []string{}
if al.AlbumArtist != "" {
item.Artists = append(item.Artists, al.AlbumArtist)
}
if al.AlbumArtistID != "" {
item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}}
item.ArtistItems = item.AlbumArtists
}
if al.MaxYear > 0 {
item.ProductionYear = new(al.MaxYear)
}
item.PremiereDate = premiereDate(al.Date, al.MaxYear)
if len(al.Genres) > 0 {
for _, g := range al.Genres {
item.Genres = append(item.Genres, g.Name)
item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name})
}
}
if fields.Has("Studios") {
for _, label := range al.Tags.Values(model.TagRecordLabel) {
id := EncodeID(model.NewTag(model.TagRecordLabel, label).ID)
item.Studios = append(item.Studios, NameGuidPair{Name: label, Id: id})
}
}
item.NormalizationGain = al.RGAlbumGain
if fields.Has("SortName") {
item.SortName = sortName(al.SortAlbumName, al.OrderAlbumName, al.Name)
}
return item
}
func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto {
tag, blurs, ratio := primaryImage(ar.ItemImage, ar.ID, fields)
item := BaseItemDto{
Name: ar.Name,
Id: EncodeID(ar.ID),
Type: "MusicArtist",
IsFolder: true,
AlbumCount: new(ar.AlbumCount),
SongCount: new(ar.SongCount),
DateCreated: JellyfinDate(ar.CreatedAt),
ImageBlurHashes: blurs,
PrimaryImageAspectRatio: ratio,
BackdropImageTags: []string{},
UserData: UserData(ar.Annotations, ar.ID),
}
if tag != "" {
item.ImageTags = map[string]string{"Primary": tag}
}
if fields.Has("SortName") {
item.SortName = sortName(ar.SortArtistName, ar.OrderArtistName, ar.Name)
}
return item
}
func LibraryToBaseItem(lib model.Library) BaseItemDto {
id := EncodeLibraryID(lib.ID)
return BaseItemDto{
Id: id,
Name: lib.Name,
SortName: lib.Name,
Type: "CollectionFolder",
CollectionType: "music",
IsFolder: true,
Path: lib.Path,
LocationType: "FileSystem",
DateCreated: JellyfinDate(&lib.CreatedAt),
ChildCount: new(lib.TotalAlbums),
UserData: &UserItemDataDto{Key: id, ItemId: id},
BackdropImageTags: []string{},
}
}
func GenreToBaseItem(g model.Genre) BaseItemDto {
return BaseItemDto{
Name: g.Name,
Id: EncodeID(g.ID),
Type: "MusicGenre",
IsFolder: true,
BackdropImageTags: []string{},
}
}
func StudioToBaseItem(t model.Tag) BaseItemDto {
return BaseItemDto{
Name: t.TagValue,
Id: EncodeID(t.ID),
Type: "Studio",
BackdropImageTags: []string{},
}
}
func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto {
tag, blurs, ratio := primaryImage(p.ItemImage, p.ID, fields)
item := BaseItemDto{
Name: p.Name,
Id: EncodeID(p.ID),
Type: "Playlist",
Path: "/data/playlists/" + p.ID,
IsFolder: true,
MediaType: "Audio",
ChildCount: new(p.SongCount),
RunTimeTicks: TicksFromSeconds(p.Duration),
DateCreated: JellyfinDate(&p.CreatedAt),
ImageBlurHashes: blurs,
PrimaryImageAspectRatio: ratio,
BackdropImageTags: []string{},
UserData: UserData(p.Annotations, p.ID),
}
if tag != "" {
item.ImageTags = map[string]string{"Primary": tag}
}
if fields.Has("SortName") {
item.SortName = p.Name
}
return item
}
func LyricDtoFromLyrics(mf model.MediaFile, lyrics model.Lyrics) LyricDto {
d := LyricDto{
Metadata: LyricMetadata{
Artist: cmp.Or(lyrics.DisplayArtist, mf.Artist),
Album: mf.Album,
Title: cmp.Or(lyrics.DisplayTitle, mf.Title),
Length: TicksFromSeconds(mf.Duration),
IsSynced: lyrics.Synced,
},
Lyrics: make([]LyricLine, 0, len(lyrics.Line)),
}
if lyrics.Offset != nil {
offset := TicksFromMillis(*lyrics.Offset)
d.Metadata.Offset = &offset
}
for _, line := range lyrics.Line {
out := LyricLine{Text: line.Value}
if lyrics.Synced {
if line.Start == nil {
continue
}
start := TicksFromMillis(*line.Start)
out.Start = &start
for _, cue := range line.Cue {
if cue.Start == nil {
continue
}
c := LyricLineCue{
Position: cue.ByteStart,
EndPosition: cue.ByteEnd,
Start: TicksFromMillis(*cue.Start),
}
if cue.End != nil {
end := TicksFromMillis(*cue.End)
c.End = &end
}
out.Cues = append(out.Cues, c)
}
}
d.Lyrics = append(d.Lyrics, out)
}
return d
}
func NewSessionInfo(u *model.User, client, deviceID, deviceName, version, serverID string) *SessionInfo {
now := time.Now()
return &SessionInfo{
Id: EncodeID(id.NewHash(client, deviceID)),
UserId: EncodeID(u.ID),
UserName: u.UserName,
Client: client,
DeviceId: deviceID,
DeviceName: deviceName,
ApplicationVersion: version,
ServerId: serverID,
LastActivityDate: JellyfinDate(&now),
IsActive: true,
PlayableMediaTypes: []string{"Audio"},
SupportedCommands: []string{},
AdditionalUsers: []any{},
NowPlayingQueue: []any{},
PlayState: PlayerStateInfo{RepeatMode: "RepeatNone", PlaybackOrder: "Default"},
}
}