🏡 index : quine-global/quintodrome.git

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

// sortName must match the persistence ORDER BY key (see setSortMappings): Finamp's A-Z jump
// scans SortName client-side, and any mismatch with the server's sort order scrolls to the top.
func sortName(sortTag, orderName, displayName string) string {
	if conf.Server.PreferSortTags {
		return cmp.Or(sortTag, orderName, displayName)
	}
	return cmp.Or(orderName, displayName)
}

// Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond.
const ticksPerMillis = 10_000

func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1000 * ticksPerMillis) }

// TicksFromMillis converts milliseconds (Navidrome lyric timestamps) to ticks.
func TicksFromMillis(ms int64) int64 { return ms * ticksPerMillis }

// MillisFromTicks converts ticks (client-reported playback positions) to milliseconds.
func MillisFromTicks(ticks int64) int64 { return ticks / ticksPerMillis }

// premiereDate converts a possibly partial date tag ("2007", "2007-02") into the ISO 8601
// PremiereDate clients parse, falling back to year; nil when neither exists.
func premiereDate(date string, year int) *string {
	d := date
	switch len(d) {
	case 4:
		d += "-01-01"
	case 7:
		d += "-01"
	case 10: // already yyyy-mm-dd
	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
}

// Dates use .NET's round-trip layout, 7 fractional digits and all: Manet rejects plain RFC3339.
const jellyfinDateLayout = "2006-01-02T15:04:05.0000000Z07:00"

// JellyfinDate formats t as the date string clients expect, or "" for the zero time so the
// field is omitted rather than sent as a meaningless epoch.
func JellyfinDate(t *time.Time) string {
	if t == nil || t.IsZero() {
		return ""
	}
	return t.UTC().Format(jellyfinDateLayout)
}

// channelLayout maps a channel count to the label Jellyfin clients expect on a MediaStream.
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 ""
	}
}

// MediaSourceFromMediaFile builds the MediaSourceInfo for direct playback of mf's source file.
// Shared by SongToBaseItem and getPlaybackInfo so Size/Bitrate match across browse and /PlaybackInfo
// responses (Finamp's download dialog reads MediaSources[0].Size from the browse response).
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, // Navidrome stores kbps; Jellyfin's BitRate is bps.
		Channels:      mf.Channels,
		SampleRate:    mf.SampleRate,
		ChannelLayout: channelLayout(mf.Channels),
	}
	// Finamp gates its lyrics view on a Lyric stream in PlaybackInfo, not on HasLyrics.
	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, // Navidrome stores kbps; Jellyfin's Bitrate is bps.
		SupportsDirectPlay:   true,
		SupportsDirectStream: true,
		SupportsTranscoding:  true,
		IsRemote:             false,
		SupportsProbing:      true,
		MediaStreams:         streams,
		MediaAttachments:     []any{},
		Formats:              []string{},
	}
}

// minLikeRating is Jellyfin's UserItemData.MinLikeValue: a rating at or above it reads as Likes.
const minLikeRating = 6.5

// StarsFromRating maps Jellyfin's 0-10 rating to 0-5 stars, rejecting out-of-range values and NaN.
// A nonzero rating floors at one star, since 0 stars means "not rated".
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
}

// StarsFromLikes mirrors Jellyfin, which stores likes as Rating 10 and dislikes as Rating 1.
func StarsFromLikes(likes bool) int {
	if likes {
		return 5
	}
	return 1
}

func UserData(a model.Annotations, itemID string) *UserItemDataDto {
	// Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto.
	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 // Navidrome 0-5 -> Jellyfin 0-10
		d.Rating = &r
		d.Likes = new(r >= minLikeRating)
	}
	if s := JellyfinDate(a.PlayDate); s != "" {
		d.LastPlayedDate = &s
	}
	return d
}

// SongToBaseItem maps a media file to an Audio BaseItemDto. MediaSources and SortName are attached
// only when the request's Fields asks for them, mirroring real Jellyfin (which omits both from a
// plain list response); a nil fields set means neither.
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)
	}
	// Real Jellyfin splits Artists/ArtistItems per track artist (AlbumArtists stays a single credit).
	// Participants holds the per-artist list; fall back to the flattened display fields when absent.
	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)}}
	}
	// dB to apply at the RG2 -18 LUFS reference, same convention real Jellyfin uses; no conversion.
	item.NormalizationGain = mf.RGTrackGain
	item.AlbumNormalizationGain = mf.RGAlbumGain
	if mf.Year > 0 {
		item.ProductionYear = new(mf.Year)
	}
	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}
	}
	// Clients prefer ImageTags.Primary over AlbumId, so a track's own cover must win here.
	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) {
		// Nothing enqueues media files: advertising the id is what makes a client ask, and that
		// request is what extracts the embedded art and queues the track.
		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
}

// primaryImage never fakes a blurhash: clients key their cover cache on the value, which would
// pin a stale cover forever.
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})
		}
	}
	// Jellyfin leaves Studios empty for music; we expose record labels here to match our /Studios
	// list and StudioIds= filter, so a client can display and click through to filter by label.
	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})
		}
	}
	// The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same
	// convention as tracks; clients read it off the album item as NormalizationGain.
	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
}

// LibraryToBaseItem maps a library to the CollectionFolder item clients browse as a top-level node.
// Manet keeps no library, and so syncs nothing, unless it carries the fields Jellyfin sends here.
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{},
	}
}

// PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto.
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",
		// Synthetic path: Jellify only surfaces playlists whose Path contains "data" (real Jellyfin
		// stores them under its data folder), so without this its Playlists tab hides them all.
		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}
	}
	// Playlists have no sort tag; the repository orders them by name.
	if fields.Has("SortName") {
		item.SortName = p.Name
	}
	return item
}

// LyricDtoFromLyrics maps one lyric track to Jellyfin's LyricDto. Clients infer synced-vs-plain
// from per-line Start presence, so synced drops start-less lines and unsynced never emits Start.
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
}

// NewSessionInfo's Id is stable per client install, as Jellyfin reuses a device's session across logins.
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"},
	}
}