// Shared types, constants and pure helpers for the /jellyfin workspace panels. // // The types mirror Jellyfin's `BaseItemDto` rather than reshaping it, because the sidecar's `/_officer/*` // façade is a pass-through for the item shapes themselves — what it adds is the user id, the Fields list and // the playback negotiation, not a different vocabulary. Every field here is optional in Jellyfin's own schema // and is optional here too: an Episode has no `CollectionType`, a Movie has no `SeriesId`, and a library // mid-scan can return an item with nothing but an Id. // // VIDEO ONLY. The owner's music is Officer's own /music player, and this machine runs separate Jellyfin // instances for albums and DJ sets; the sidecar already filters music libraries out of `/views` and `/home`. export const JELLYFIN_SECTIONS = [ { id: 'home', label: 'Home' }, { id: 'library', label: 'Library' }, { id: 'search', label: 'Search' }, { id: 'settings', label: 'Connection' }, ] as const; export type JellyfinSectionId = (typeof JELLYFIN_SECTIONS)[number]['id']; /** Where /jellyfin lands, and where an unrecognised section redirects to. */ export const DEFAULT_JELLYFIN_SECTION: JellyfinSectionId = 'home'; export const isJellyfinSection = (value: string | undefined): value is JellyfinSectionId => JELLYFIN_SECTIONS.some((s) => s.id === value); /** The one place the section URL is spelled, so nav, guard and deep links cannot drift apart. */ export const jellyfinSectionPath = (id: JellyfinSectionId) => `/jellyfin/${id}`; /** Which library the browse grid is showing — `?view=`. */ export const VIEW_PARAM = 'view'; /** Which item is open in the detail sheet — `?item=`. */ export const ITEM_PARAM = 'item'; /** Which item is playing — `?play=`. Separate from `item` so closing the player returns to the detail. */ export const PLAY_PARAM = 'play'; /** The search text — `?q=…`. */ export const QUERY_PARAM = 'q'; /** * The href that opens an item, keeping the page you opened it from. * * Selection is a query parameter rather than a route because the detail sheet is an overlay ON the grid: the * grid stays mounted and scrolled behind it, and closing is dropping one parameter. Building the href from the * CURRENT params is what makes that work in both directions — open an episode from a search and closing it * puts the search back, unchanged. */ export function itemHref(pathname: string, params: URLSearchParams, id: string, play = false): string { const next = new URLSearchParams(params); next.set(play ? PLAY_PARAM : ITEM_PARAM, id); if (play) next.delete(ITEM_PARAM); return `${pathname}?${next}`; } /** The href that closes whichever overlay is open, leaving everything else about the page alone. */ export function closeHref(pathname: string, params: URLSearchParams, which: typeof ITEM_PARAM | typeof PLAY_PARAM) { const next = new URLSearchParams(params); next.delete(which); const qs = next.toString(); return qs ? `${pathname}?${qs}` : pathname; } // ── Wire types ──────────────────────────────────────────────────────────────────────────────────── export type UserItemData = { PlaybackPositionTicks?: number; PlayCount?: number; IsFavorite?: boolean; Played?: boolean; PlayedPercentage?: number | null; UnplayedItemCount?: number; }; export type MediaStream = { Type?: string; Index?: number; Codec?: string | null; Language?: string | null; DisplayTitle?: string | null; Height?: number | null; Width?: number | null; Channels?: number | null; IsDefault?: boolean; IsForced?: boolean; }; export type MediaSource = { Id?: string; Name?: string | null; Container?: string | null; Size?: number | null; RunTimeTicks?: number | null; Bitrate?: number | null; MediaStreams?: MediaStream[]; SupportsDirectPlay?: boolean; SupportsDirectStream?: boolean; }; export type Person = { Id?: string; Name?: string; Role?: string | null; Type?: string; PrimaryImageTag?: string }; export type JellyItem = { Id: string; Name?: string; Type?: string; CollectionType?: string | null; ProductionYear?: number | null; PremiereDate?: string | null; EndDate?: string | null; Status?: string | null; Overview?: string | null; Taglines?: string[]; Genres?: string[]; OfficialRating?: string | null; CommunityRating?: number | null; CriticRating?: number | null; RunTimeTicks?: number | null; IndexNumber?: number | null; ParentIndexNumber?: number | null; SeriesId?: string | null; SeriesName?: string | null; SeasonId?: string | null; SeasonName?: string | null; ParentId?: string | null; ChildCount?: number | null; RecursiveItemCount?: number | null; PrimaryImageAspectRatio?: number | null; ImageTags?: Record; BackdropImageTags?: string[]; ParentBackdropItemId?: string | null; ParentBackdropImageTags?: string[]; SeriesPrimaryImageTag?: string | null; Studios?: { Id?: string; Name?: string }[]; People?: Person[]; MediaSources?: MediaSource[]; MediaStreams?: MediaStream[]; UserData?: UserItemData; }; export type ItemsResponse = { Items?: JellyItem[]; TotalRecordCount?: number }; export type HomeResponse = { views: JellyItem[]; resume: JellyItem[]; nextUp: JellyItem[]; latest: { viewId: string; viewName: string; collectionType: string | null; items: JellyItem[] }[]; }; export type AudioTrack = { index: number; label: string; language: string | null; codec: string | null; channels: number | null; isDefault: boolean; }; export type SubtitleTrack = { index: number; label: string; language: string | null; codec: string | null; isDefault: boolean; isForced: boolean; /** A `/_jf` path to a WebVTT file, or null when this track cannot be delivered as one. */ url: string | null; /** Why `url` is null, in words the picker shows. Null when the track is fine. */ unavailable: string | null; }; export type QualityRung = { label: string; videoBitrate: number; maxWidth: number }; /** What `/_officer/items/{id}/playback` answers — the sidecar's own shape, not Jellyfin's. */ export type PlaybackPlan = { playMethod: 'DirectPlay' | 'DirectStream' | 'Transcode'; url: string; /** A direct file seeks in the browser; a live transcode does not, and the player restarts it at an offset. */ seekable: boolean; hlsUrl: string | null; playSessionId: string | null; mediaSourceId: string | null; mediaSource: MediaSource; startSeconds: number; runtimeSeconds: number | null; audio: AudioTrack[]; subtitles: SubtitleTrack[]; /** Echoed back so the picker shows what is actually playing, not what was last clicked. */ audioStreamIndex: number | null; /** Null means Auto — the server's own copy-or-encode decision rather than a rung the owner picked. */ maxStreamingBitrate: number | null; qualityRungs: QualityRung[]; }; // ── Media URLs ──────────────────────────────────────────────────────────────────────────────────── // // An cannot send an Authorization header, so media URLs carry `?token=` instead — the escape hatch // userMiddleware already supports for /