All three were found by running the thing rather than reading it, and the first one is serious. 1. probeable() compared the LIBRARY kind against a hardcoded list. Splitting video into movies/tv/adult/hentai left every new kind out of that list, so a movies or tv library was never probed and stored no codec facts at all. With no facts, resolve cannot tell an unplayable file from a playable one: an HEVC file reported "playable via html5" for a browser that cannot decode HEVC, and transcoding would never have triggered for anything. It now tests the kind the rows carry, through library.ItemKindFor, so every video library is probed exactly as the original video kind was and a future video kind cannot repeat this. 2. jellyfin CollectionType() had no case for the new kinds, so a Jellyfin client saw movies, tv, adult and hentai libraries with no collection type. 3. kindNoun() fell through to its default for the new kinds, which reads "tv" in a sentence rather than "shows". Also hardened the empty-kind fallback in upsertItem to resolve through ItemKindFor, so it cannot store a library's own name where 'video' belongs. Verified end-to-end: codec facts are recorded again (v=h264 and v=hevc), an h264 file resolves to engine html5, and the HEVC file resolves to engine transcode. That last line is the whole feature working: scan records the facts, resolve sees the browser cannot decode it, and the server offers to re-encode it instead of saying the file is unplayable.
539 lines
19 KiB
Go
539 lines
19 KiB
Go
// 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 }
|