// 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 ( "slices" "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"/"Series"/"Season"/"Episode"/"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"` // IndexNumber is the number within the parent — an episode number for an // Episode, a season number for a Season. ParentIndexNumber is the season an // episode belongs to. Both are omitted rather than sent as 0 for everything // that is not television, so a movie's payload is unchanged. IndexNumber int `json:"IndexNumber,omitempty"` ParentIndexNumber int `json:"ParentIndexNumber,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 item to the Jellyfin item type. // // It takes the item, not the kind, because television is not a kind: an EPISODE // stays kind='video' (migration 0013) and is told from a film by its episode // number. A function of the kind alone would label every episode a Movie and // clients would file a show as a film. func ItemType(it catalog.Item) string { switch it.Kind { case "series": return "Series" case "season": return "Season" case "video": if it.EpisodeNumber > 0 { return "Episode" } 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"), so a series and a // season are still Video: they are folders, but they sit in a video library. func MediaType(kind string) string { switch kind { case "video", "series", "season": 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. tvshows is what makes a client open a // series in its television view rather than as a generic folder. func CollectionType(kind string) string { switch kind { case "series": return "tvshows" case "video", "movies", "adult", "hentai": return "movies" case "tv": return "tvshows" 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 Series does not receive films. An unknown type maps // to nothing rather than to everything. // // An Episode maps to kind 'video' with nothing else added, because an episode IS // a video row; the handler narrows the result to rows that carry an episode // number (see EpisodesOnly). func KindsForTypes(types []string) []string { var out []string add := func(kind string) { // Episode and Movie are both 'video'; a client asking for both must not // produce the same kind twice in the IN list. if !slices.Contains(out, kind) { out = append(out, kind) } } for _, t := range types { switch t { case "Movie", "Episode": add("video") case "Series": add("series") case "Season": add("season") case "Audio": add("audiobooks") add("music") case "Book": add("ebooks") add("comics") case "Photo": add("photos") } } return out } // EpisodesOnly reports whether a requested set of Jellyfin types means episodes // and not films. Both are kind 'video', so the episode number is what separates // them. A Movie anywhere in the list wins, however it is ordered: a client // browsing a mixed library asks for both and means both. func EpisodesOnly(types []string) bool { episode := false for _, t := range types { switch t { case "Episode": episode = true case "Movie": return false } } return episode } // ToBaseItemDto renders one ItemView. includeMediaSources is set from the // Fields query parameter; lists normally omit sources to keep responses small. // // Television is dispatched to SeriesDto / SeasonDto / EpisodeDto, which are the // three shapes below; everything else renders here. func ToBaseItemDto(v ItemView, includeMediaSources bool, apiKey string) BaseItemDto { switch { case v.Item.Kind == "series": return SeriesDto(v) case v.Item.Kind == "season": return SeasonDto(v) case v.Item.Kind == "video" && v.Item.EpisodeNumber > 0: return EpisodeDto(v, includeMediaSources, apiKey) } return playableDto(v, includeMediaSources, apiKey) } // playableDto is the shape of everything a client can actually play: the file's // facts, the ParentId it is browsed under, and the per-user overlay. func playableDto(v ItemView, includeMediaSources bool, apiKey string) BaseItemDto { dto := BaseItemDto{ Id: ItemID(v.Item.ID), Name: v.Item.Title, Type: ItemType(v.Item), 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 = userData(v) if includeMediaSources { dto.MediaSources = []MediaSourceInfo{BuildMediaSource(v.Item, v.File, apiKey)} } return dto } // ── the television tree ───────────────────────────────────────────────── // // Jellyfin has no separate series DTO: /Items answers every type with // BaseItemDto, and a client tells them apart by Type. These three renderers // exist because the television shapes differ in more than the Type string — a // series and a season are folders with no file facts, and an episode carries // the two numbers and a ParentId a client drills back up through. // SeriesDto renders a series: a folder with no playable file, a tvshows // collection so a client opens its television view, and no RunTimeTicks. func SeriesDto(v ItemView) BaseItemDto { dto := BaseItemDto{ Id: ItemID(v.Item.ID), Name: v.Item.Title, Type: "Series", MediaType: MediaType(v.Item.Kind), CollectionType: CollectionType(v.Item.Kind), IsFolder: true, SortName: v.SortTitle, } if v.ParentID > 0 { dto.ParentId = ItemID(v.ParentID) } if v.HasCover { dto.ImageTags = map[string]string{"Primary": ItemID(v.Item.ID)} } return dto } // SeasonDto renders a season: a folder, numbered, whose IndexNumber is the // season number the client sorts by. func SeasonDto(v ItemView) BaseItemDto { dto := SeriesDto(v) dto.Type = "Season" dto.CollectionType = "" // only a series is a collection dto.IndexNumber = v.Item.SeasonNumber return dto } // EpisodeDto renders one episode: a playable item that is this, its episode // number as IndexNumber, its season as ParentIndexNumber, and its season as // ParentId so a client can climb back out of the episode list. func EpisodeDto(v ItemView, includeMediaSources bool, apiKey string) BaseItemDto { dto := playableDto(v, includeMediaSources, apiKey) dto.Type = "Episode" dto.IndexNumber = v.Item.EpisodeNumber dto.ParentIndexNumber = v.Item.SeasonNumber return dto } // userData is the per-user overlay, zeroed when the user has never touched the // item. UserData is always present on a playable item: clients read it // unconditionally and a missing key is a null reference. func userData(v ItemView) *UserItemDataDto { return &UserItemDataDto{ PlaybackPositionTicks: MsToTicks(v.PositionMS), PlayedPercentage: v.Percent * 100, PlayCount: v.PlayCount, IsFavorite: v.Favorite, Played: v.Finished, } } // 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 }