feat(jellyfin): add Jellyfin DTOs and model mappers

Adds BaseItemDto, QueryResult, UserItemDataDto, UserDto,
AuthenticationResult, MediaSourceInfo, PlaybackInfoResponse,
NameGuidPair and SessionInfo DTOs to server/jellyfin/dto, plus
model-to-DTO mappers for songs, albums, artists and genres that
later browse/stream/write endpoints will consume.
This commit is contained in:
Deluan 2026-07-05 14:26:24 -04:00
commit 2e0e3264c7
4 changed files with 275 additions and 0 deletions

View file

@ -19,3 +19,99 @@ type SystemInfo struct {
SupportsLibraryMonitor bool `json:"SupportsLibraryMonitor"`
CachePath string `json:"CachePath,omitempty"`
}
type NameGuidPair struct {
Name string `json:"Name"`
Id string `json:"Id"`
}
type UserItemDataDto struct {
Rating *float64 `json:"Rating,omitempty"`
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
PlayCount int `json:"PlayCount"`
IsFavorite bool `json:"IsFavorite"`
Played bool `json:"Played"`
Key string `json:"Key"`
ItemId string `json:"ItemId,omitempty"`
LastPlayedDate *string `json:"LastPlayedDate,omitempty"`
}
type BaseItemDto struct {
Name string `json:"Name"`
ServerId string `json:"ServerId,omitempty"`
Id string `json:"Id"`
Type string `json:"Type"`
IsFolder bool `json:"IsFolder"`
MediaType string `json:"MediaType,omitempty"`
CollectionType string `json:"CollectionType,omitempty"`
ParentId string `json:"ParentId,omitempty"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
IndexNumber *int `json:"IndexNumber,omitempty"`
ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"`
ProductionYear *int `json:"ProductionYear,omitempty"`
Album string `json:"Album,omitempty"`
AlbumId string `json:"AlbumId,omitempty"`
AlbumArtist string `json:"AlbumArtist,omitempty"`
AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"`
AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"`
Artists []string `json:"Artists,omitempty"`
ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"`
Genres []string `json:"Genres,omitempty"`
ChildCount *int `json:"ChildCount,omitempty"`
SongCount *int `json:"SongCount,omitempty"`
AlbumCount *int `json:"AlbumCount,omitempty"`
ImageTags map[string]string `json:"ImageTags,omitempty"`
BackdropImageTags []string `json:"BackdropImageTags"`
UserData *UserItemDataDto `json:"UserData,omitempty"`
MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"`
Container string `json:"Container,omitempty"`
CanDownload bool `json:"CanDownload"`
}
type QueryResult struct {
Items []BaseItemDto `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
StartIndex int `json:"StartIndex"`
}
type UserDto struct {
Name string `json:"Name"`
ServerId string `json:"ServerId,omitempty"`
Id string `json:"Id"`
HasPassword bool `json:"HasPassword"`
HasConfiguredPassword bool `json:"HasConfiguredPassword"`
HasConfiguredEasyPassword bool `json:"HasConfiguredEasyPassword"`
PrimaryImageTag string `json:"PrimaryImageTag,omitempty"`
}
type SessionInfo struct {
Id string `json:"Id"`
UserId string `json:"UserId"`
}
type AuthenticationResult struct {
User *UserDto `json:"User"`
SessionInfo *SessionInfo `json:"SessionInfo,omitempty"`
AccessToken string `json:"AccessToken"`
ServerId string `json:"ServerId"`
}
type MediaSourceInfo struct {
Id string `json:"Id"`
Path string `json:"Path,omitempty"`
Protocol string `json:"Protocol"`
Container string `json:"Container,omitempty"`
Size int64 `json:"Size,omitempty"`
Name string `json:"Name,omitempty"`
IsRemote bool `json:"IsRemote"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
SupportsTranscoding bool `json:"SupportsTranscoding"`
SupportsDirectStream bool `json:"SupportsDirectStream"`
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
Type string `json:"Type"`
}
type PlaybackInfoResponse struct {
MediaSources []MediaSourceInfo `json:"MediaSources"`
PlaySessionId string `json:"PlaySessionId"`
}

View file

@ -0,0 +1,17 @@
package dto
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestDto(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Jellyfin DTO Suite")
}

View file

@ -0,0 +1,114 @@
package dto
import "github.com/navidrome/navidrome/model"
func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1e7) }
func intPtr(v int) *int { return &v }
func UserData(a model.Annotations, itemID string) *UserItemDataDto {
d := &UserItemDataDto{
PlayCount: int(a.PlayCount),
IsFavorite: a.Starred,
Played: a.PlayCount > 0,
Key: itemID,
ItemId: itemID,
}
if a.Rating > 0 {
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
d.Rating = &r
}
return d
}
func SongToBaseItem(mf model.MediaFile) BaseItemDto {
item := BaseItemDto{
Name: mf.Title,
Id: mf.ID,
Type: "Audio",
MediaType: "Audio",
IsFolder: false,
ParentId: mf.AlbumID,
Album: mf.Album,
AlbumId: mf.AlbumID,
AlbumArtist: mf.AlbumArtist,
Artists: []string{mf.Artist},
RunTimeTicks: TicksFromSeconds(mf.Duration),
IndexNumber: intPtr(mf.TrackNumber),
ParentIndexNumber: intPtr(mf.DiscNumber),
Container: mf.Suffix,
CanDownload: true,
BackdropImageTags: []string{},
UserData: UserData(mf.Annotations, mf.ID),
}
if mf.Year > 0 {
item.ProductionYear = intPtr(mf.Year)
}
if len(mf.Genres) > 0 {
for _, g := range mf.Genres {
item.Genres = append(item.Genres, g.Name)
}
} else if mf.Genre != "" {
item.Genres = []string{mf.Genre}
}
// Finamp resolves song art via AlbumId + a non-empty AlbumPrimaryImageTag.
if mf.AlbumID != "" {
item.AlbumPrimaryImageTag = mf.AlbumID
}
return item
}
func AlbumToBaseItem(al model.Album) BaseItemDto {
item := BaseItemDto{
Name: al.Name,
Id: al.ID,
Type: "MusicAlbum",
IsFolder: true,
ParentId: al.AlbumArtistID,
AlbumArtist: al.AlbumArtist,
Album: al.Name,
ChildCount: intPtr(al.SongCount),
SongCount: intPtr(al.SongCount),
RunTimeTicks: TicksFromSeconds(al.Duration),
ImageTags: map[string]string{"Primary": al.ID},
BackdropImageTags: []string{},
UserData: UserData(al.Annotations, al.ID),
}
if al.AlbumArtistID != "" {
item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: al.AlbumArtistID}}
item.ArtistItems = item.AlbumArtists
}
if al.MaxYear > 0 {
item.ProductionYear = intPtr(al.MaxYear)
}
if len(al.Genres) > 0 {
for _, g := range al.Genres {
item.Genres = append(item.Genres, g.Name)
}
}
return item
}
func ArtistToBaseItem(ar model.Artist) BaseItemDto {
return BaseItemDto{
Name: ar.Name,
Id: ar.ID,
Type: "MusicArtist",
IsFolder: true,
AlbumCount: intPtr(ar.AlbumCount),
SongCount: intPtr(ar.SongCount),
ImageTags: map[string]string{"Primary": ar.ID},
BackdropImageTags: []string{},
UserData: UserData(ar.Annotations, ar.ID),
}
}
func GenreToBaseItem(g model.Genre) BaseItemDto {
return BaseItemDto{
Name: g.Name,
Id: g.ID,
Type: "MusicGenre",
IsFolder: true,
BackdropImageTags: []string{},
}
}

View file

@ -0,0 +1,48 @@
package dto
import (
"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,
}
mf.PlayCount = 2
mf.Starred = true
item := SongToBaseItem(mf)
Expect(item.Type).To(Equal("Audio"))
Expect(item.IsFolder).To(BeFalse())
Expect(item.Id).To(Equal("song-1"))
Expect(item.AlbumId).To(Equal("alb-1"))
Expect(item.ParentId).To(Equal("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())
})
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}
item := AlbumToBaseItem(al)
Expect(item.Type).To(Equal("MusicAlbum"))
Expect(item.IsFolder).To(BeTrue())
Expect(item.ParentId).To(Equal("art-1"))
Expect(*item.ProductionYear).To(Equal(1999))
Expect(*item.ChildCount).To(Equal(10))
})
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.AlbumCount).To(Equal(2))
})
})