// Package dto is the Jellyfin wire contract: the request and response shapes // real clients (the web app, iOS, Android, Roku, TV) send and expect, plus the // pure functions that build them from Vellum's own types. // // It is deliberately separate from internal/jellyfin, which owns the HTTP // handlers and the queries. A DTO here knows nothing about a request, a // response writer or a database row: it maps data structures. That is what // makes the contract reviewable on its own — the field names below are the ones // clients are coded against, and a rename in here silently breaks an app that // nobody in this repository can see failing. // // Every JSON tag below is load-bearing. Do not rename one without reading the // Jellyfin OpenAPI first. package dto import ( "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/catalog" "github.com/mithral/vellum/internal/library" ) // This file defines a documented SUBSET of the Jellyfin DTOs. Jellyfin's // BaseItemDto has roughly a hundred fields; real clients tolerate the absence // of most of them, and the ones below are what browse and playback actually // consume. Every struct is PascalCase on the wire because that is the Jellyfin // convention (the web app's own API is snake_case and is untouched). // UserDto is the subset of the Jellyfin user shape clients read. type UserDto struct { Name string `json:"Name"` ServerId string `json:"ServerId,omitempty"` Id string `json:"Id"` HasPassword bool `json:"HasPassword"` HasConfiguredPassword bool `json:"HasConfiguredPassword"` Policy *UserPolicy `json:"Policy,omitempty"` Configuration *UserConfig `json:"Configuration,omitempty"` } // UserPolicy carries the two things a client needs from a user's policy: the // admin flag and which library folders the account can see. EnabledFolders is // the user's visible library ids rendered as Jellyfin ids, so a client can hide // views it is not allowed to open. type UserPolicy struct { IsAdministrator bool `json:"IsAdministrator"` IsDisabled bool `json:"IsDisabled"` EnableAllFolders bool `json:"EnableAllFolders"` EnabledFolders []string `json:"EnabledFolders"` EnableMediaPlayback bool `json:"EnableMediaPlayback"` EnableContentDownloading bool `json:"EnableContentDownloading"` } // UserConfig is a near-empty subset; clients only require the key to exist. type UserConfig struct { EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"` } // UserItemDataDto is per-user state for one item. // // Ticks are 100ns units. Vellum stores milliseconds, so the conversion is // x10000 (ms -> ticks) and /10000 (ticks -> ms). type UserItemDataDto struct { PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` PlayedPercentage float64 `json:"PlayedPercentage,omitempty"` PlayCount int `json:"PlayCount"` IsFavorite bool `json:"IsFavorite"` Played bool `json:"Played"` } // BaseItemDto is the browse shape. Type is the Jellyfin item type // ("Movie"/"Audio"/"Book"/"Photo"/"CollectionFolder"/"Folder"), mapped from // Vellum's media kind. type BaseItemDto struct { Id string `json:"Id"` Name string `json:"Name"` Type string `json:"Type"` MediaType string `json:"MediaType,omitempty"` CollectionType string `json:"CollectionType,omitempty"` Container string `json:"Container,omitempty"` RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` ProductionYear int `json:"ProductionYear,omitempty"` ParentId string `json:"ParentId,omitempty"` IsFolder bool `json:"IsFolder"` SortName string `json:"SortName,omitempty"` Path string `json:"Path,omitempty"` ImageTags map[string]string `json:"ImageTags,omitempty"` UserData *UserItemDataDto `json:"UserData,omitempty"` MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` } // MediaSourceInfo is one playable source. Vellum never transcodes, so // SupportsTranscoding is always false and TranscodingUrl is never set. type MediaSourceInfo struct { Id string `json:"Id"` Name string `json:"Name"` Path string `json:"Path,omitempty"` Container string `json:"Container,omitempty"` RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` Size int64 `json:"Size,omitempty"` SupportsDirectPlay bool `json:"SupportsDirectPlay"` SupportsDirectStream bool `json:"SupportsDirectStream"` SupportsTranscoding bool `json:"SupportsTranscoding"` DirectStreamUrl string `json:"DirectStreamUrl,omitempty"` Protocol string `json:"Protocol"` Type string `json:"Type"` IsRemote bool `json:"IsRemote"` MediaStreams []MediaStream `json:"MediaStreams,omitempty"` } // MediaStream describes one track inside a file, using the codec facts the // scanner wrote from ffprobe. type MediaStream struct { Type string `json:"Type"` // "Video" | "Audio" Codec string `json:"Codec,omitempty"` Index int `json:"Index"` IsDefault bool `json:"IsDefault"` Width int `json:"Width,omitempty"` Height int `json:"Height,omitempty"` } // PlaybackInfoResponse is the response to PlaybackInfo. ErrorCode is set only // when the client demanded a transcode Vellum will not perform. type PlaybackInfoResponse struct { MediaSources []MediaSourceInfo `json:"MediaSources"` PlaySessionId string `json:"PlaySessionId,omitempty"` ErrorCode string `json:"ErrorCode,omitempty"` } // ItemQueryResult is the browse envelope. type ItemQueryResult struct { Items []BaseItemDto `json:"Items"` TotalRecordCount int `json:"TotalRecordCount"` StartIndex int `json:"StartIndex"` } // SystemInfoPublic is the discovery document clients fetch before they have a // token. Only the four fields the task requires (plus a couple clients read). type SystemInfoPublic struct { Id string `json:"Id"` ServerName string `json:"ServerName"` Version string `json:"Version"` ProductName string `json:"ProductName"` OperatingSystem string `json:"OperatingSystem,omitempty"` } // Version is the Jellyfin API version string clients compare against. Vellum // reports a conservative, widely-supported version rather than its own build // number, because clients gate features on this value. const Version = "10.8.10" // AuthByNameRequest is the AuthenticateByName body. Jellyfin spells the // password "Pw". type AuthByNameRequest struct { Username string `json:"Username"` Pw string `json:"Pw"` } // PlayReport is the body of the three playback-reporting endpoints. Jellyfin // spells these fields in PascalCase and sends PositionTicks in 100ns units. type PlayReport struct { ItemId string `json:"ItemId"` PositionTicks int64 `json:"PositionTicks"` PlaySessionId string `json:"PlaySessionId"` MediaSourceId string `json:"MediaSourceId"` IsPaused bool `json:"IsPaused"` } // ItemView is one browsable item assembled for a Jellyfin DTO. // // It composes the two types internal/catalog owns — the item row and its first // file — with the per-user overlay (progress, view count, favourite) and the // two browse-only columns catalog.Item does not carry (sort title and parent). // Nothing here reads the database or checks a grant: catalog.LoadFor loads the // item and enforces the grant, and catalog.FirstFile reads the file, so there // is exactly one implementation of "may this user read this item" and exactly // one place item and file rows are read. type ItemView struct { Item catalog.Item File catalog.File // SortTitle is a browse-only column; the DTO maps it to SortName. SortTitle string // ParentID is a browse-only column mapped to ParentId. Zero means "no // parent": item ids are positive, so zero is unambiguous. ParentID int64 // HasCover is true when the scanner extracted a primary image. HasCover bool // Per-user state, zero when the user has never touched the item. PositionMS int64 Percent float64 Finished bool PlayCount int Favorite bool } // ItemType maps a Vellum media kind to the Jellyfin item type. The four types // the task requires are Movie, Audio, Book and Photo; anything Vellum adds // later (games) degrades to Folder so a client can still show it. func ItemType(kind string) string { switch kind { case "video": return "Movie" case "audiobooks", "music": return "Audio" case "photos": return "Photo" case "ebooks", "comics": return "Book" default: return "Folder" } } // MediaType maps a Vellum media kind to the Jellyfin MediaType. It is the // coarser classification ("Video"/"Audio"/"Book"/"Photo"). func MediaType(kind string) string { switch kind { case "video": return "Video" case "audiobooks", "music": return "Audio" case "photos": return "Photo" case "ebooks", "comics": return "Book" default: return "" } } // CollectionType maps a Vellum kind to the Jellyfin collection type, which // tells a client how to render the folder. func CollectionType(kind string) string { switch kind { case "video": return "movies" case "audiobooks", "music": return "music" case "photos": return "homevideos" case "ebooks", "comics": return "books" default: return "" } } // KindsForTypes maps requested Jellyfin IncludeItemTypes back to Vellum kinds, // so a client asking for Movies does not receive ebooks. An unknown type maps // to nothing rather than to everything. func KindsForTypes(types []string) []string { var out []string for _, t := range types { switch t { case "Movie": out = append(out, "video") case "Audio": out = append(out, "audiobooks", "music") case "Book": out = append(out, "ebooks", "comics") case "Photo": out = append(out, "photos") } } return out } // ToBaseItemDto renders one ItemView. includeMediaSources is set from the // Fields query parameter; lists normally omit sources to keep responses small. func ToBaseItemDto(v ItemView, includeMediaSources bool, apiKey string) BaseItemDto { dto := BaseItemDto{ Id: ItemID(v.Item.ID), Name: v.Item.Title, Type: ItemType(v.Item.Kind), MediaType: MediaType(v.Item.Kind), Container: v.File.Container, RunTimeTicks: MsToTicks(v.File.DurationMS), ProductionYear: v.Item.Year, IsFolder: false, SortName: v.SortTitle, Path: v.File.Path, } if v.ParentID > 0 { dto.ParentId = ItemID(v.ParentID) } if v.HasCover { // The tag is the item id: stable, and it changes when the item changes. dto.ImageTags = map[string]string{"Primary": ItemID(v.Item.ID)} } dto.UserData = &UserItemDataDto{ PlaybackPositionTicks: MsToTicks(v.PositionMS), PlayedPercentage: v.Percent * 100, PlayCount: v.PlayCount, IsFavorite: v.Favorite, Played: v.Finished, } if includeMediaSources { dto.MediaSources = []MediaSourceInfo{BuildMediaSource(v.Item, v.File, apiKey)} } return dto } // BuildMediaSource renders the single direct-play source for an item. It is // shared by PlaybackInfo and by list responses that requested the MediaSources // field. Both inputs come from internal/catalog; a zero File is an item with no // file, and produces the same defaults a missing file always did. func BuildMediaSource(it catalog.Item, f catalog.File, apiKey string) MediaSourceInfo { ms := MediaSourceInfo{ Id: ItemID(it.ID), Name: it.Title, Path: f.Path, Container: f.Container, RunTimeTicks: MsToTicks(f.DurationMS), Size: f.Size, SupportsDirectPlay: true, SupportsDirectStream: true, // The hard constraint: no transcoding, ever. SupportsTranscoding: false, Protocol: "File", Type: "Default", IsRemote: false, } ms.DirectStreamUrl = directStreamURL(it, apiKey) if f.VCodec != "" { ms.MediaStreams = append(ms.MediaStreams, MediaStream{ Type: "Video", Codec: f.VCodec, Index: 0, IsDefault: true, Width: f.Width, Height: f.Height, }) } if f.ACodec != "" { ms.MediaStreams = append(ms.MediaStreams, MediaStream{ Type: "Audio", Codec: f.ACodec, Index: 1, IsDefault: true, }) } return ms } // LibraryDto renders a library as a Jellyfin folder. func LibraryDto(lib library.Library) BaseItemDto { return BaseItemDto{ Id: LibraryID(lib.ID), Name: lib.Name, Type: "CollectionFolder", CollectionType: CollectionType(lib.Kind), IsFolder: true, SortName: lib.Name, } } // User renders the Jellyfin user shape, including the visible library folders // so a client can scope its views. visibleLibIDs are the caller's granted // library ids; this function only renders them, it does not look them up. func User(u auth.User, serverID string, visibleLibIDs []int64) UserDto { folders := make([]string, 0, len(visibleLibIDs)) for _, id := range visibleLibIDs { folders = append(folders, LibraryID(id)) } return UserDto{ Name: displayName(u), ServerId: serverID, Id: ItemID(u.ID), HasPassword: u.PasswordHash != "", HasConfiguredPassword: u.PasswordHash != "", Policy: &UserPolicy{ IsAdministrator: u.IsAdmin, IsDisabled: false, EnableAllFolders: u.IsAdmin, EnabledFolders: folders, EnableMediaPlayback: true, EnableContentDownloading: true, }, Configuration: &UserConfig{}, } } // displayName falls back to the username when no display name is set. func displayName(u auth.User) string { if u.DisplayName != "" { return u.DisplayName } return u.Username } // directStreamURL builds the URL a client should play. Video items stream from // /Videos, everything else (audio, books, photos) from /Audio, which is the // same file-serving path either way. The api_key is embedded because media // elements cannot set headers. func directStreamURL(it catalog.Item, apiKey string) string { base := "/Audio/" + ItemID(it.ID) + "/stream" if it.Kind == "video" { base = "/Videos/" + ItemID(it.ID) + "/stream" } if apiKey == "" { return base } return base + "?api_key=" + apiKey } // MsToTicks converts Vellum's milliseconds to Jellyfin's 100ns ticks. func MsToTicks(ms int64) int64 { return ms * 10000 } // TicksToMS converts Jellyfin's 100ns ticks to Vellum's milliseconds. func TicksToMS(ticks int64) int64 { return ticks / 10000 }