package jellyfin import ( "database/sql" "github.com/mithral/vellum/internal/catalog" ) // 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 VisibleLibraryIDs 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"` } // 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 and ParentID are browse-only columns; the DTO maps them to // SortName and ParentId. SortTitle string ParentID sql.NullInt64 // 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 "" } } // 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.Valid { dto.ParentId = itemID(v.ParentID.Int64) } 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 } // 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 }