Files
officer a7f1a71785 jellyfin, extracted from the platform as a plugin
Eighth plugin out, and the second resurrection: like soulseek, hono.ts carried
the router import and its mount commented out since 2026-08-13, with the
permission reduced to api: [], so the server booted clean while every panel
request 404'd. jellyfin_servers was also absent from the drizzle barrel, so
bun db:push would have dropped it.

Easier than soulseek in one specific way: mountPrefix derives /jellyfin from
the appName, which is exactly what the router already hardcoded, so no frontend
call site needed repointing. Soulseek needed 47 because its appName and its old
prefix differed. The frontend also already routed through one helper, so there
were five call sites rather than forty-seven.

Self-contained, unlike transmission and soulseek: jellyfin_servers is a
registry this plugin owns outright — label, URL, encrypted token, one active —
because more than one Jellyfin is a thing someone has, and more than one
Transmission is not.

Carries its own .prettierignore for the vendored hls.mjs, rather than leaving a
stale rule behind in the platform naming a path that no longer exists.
2026-08-19 03:17:32 +00:00

332 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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=<id>`. */
export const VIEW_PARAM = 'view';
/** Which item is open in the detail sheet — `?item=<id>`. */
export const ITEM_PARAM = 'item';
/** Which item is playing — `?play=<id>`. 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<string, string>;
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 <img> cannot send an Authorization header, so media URLs carry `?token=` instead — the escape hatch
// userMiddleware already supports for <img>/<audio>/<video>, and the one the music and photos apps use. These
// point at `/_jf`, the authenticated byte pass-through, which keeps Jellyfin's own path shapes: an HLS
// playlist references its segments relatively, so any renaming here would mean rewriting m3u8 bodies.
export const JELLYFIN_BYTES = '/api/jellyfin/_jf';
const withToken = (url: string, token: string | null) =>
token ? `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : url;
/** A `/_jf/...` path from a playback plan, made absolute and authenticated for a `<video>` element. */
export const streamUrl = (path: string, token: string | null): string => withToken(`/api/jellyfin${path}`, token);
type ImageKind = 'Primary' | 'Backdrop' | 'Thumb' | 'Logo';
type ImageOptions = { tag?: string | null; maxWidth?: number; maxHeight?: number; index?: number };
export function imageUrl(id: string, kind: ImageKind, token: string | null, options: ImageOptions = {}): string {
const params = new URLSearchParams();
// The tag is the image's content hash. Including it is what makes these URLs safely cacheable forever —
// without it, a replaced poster keeps showing the old one until the browser cache expires.
if (options.tag) params.set('tag', options.tag);
if (options.maxWidth) params.set('maxWidth', String(options.maxWidth));
if (options.maxHeight) params.set('maxHeight', String(options.maxHeight));
params.set('quality', '90');
const suffix = options.index != null ? `/${options.index}` : '';
return withToken(`${JELLYFIN_BYTES}/Items/${id}/Images/${kind}${suffix}?${params}`, token);
}
/**
* The poster for a grid tile, falling back the way Jellyfin's own clients do.
*
* An episode usually has no poster of its own — it has a Thumb, and its series has the Primary. Falling back
* to the series poster is what stops a season view from being a wall of grey rectangles.
*/
export function posterUrl(item: JellyItem, token: string | null, maxWidth = 400): string | null {
const primary = item.ImageTags?.Primary;
if (primary) return imageUrl(item.Id, 'Primary', token, { tag: primary, maxWidth });
if (item.SeriesPrimaryImageTag && item.SeriesId) {
return imageUrl(item.SeriesId, 'Primary', token, { tag: item.SeriesPrimaryImageTag, maxWidth });
}
const thumb = item.ImageTags?.Thumb;
if (thumb) return imageUrl(item.Id, 'Thumb', token, { tag: thumb, maxWidth });
return null;
}
/** The wide still for a Continue Watching card: the episode's own thumb, else a backdrop, else the poster. */
export function thumbUrl(item: JellyItem, token: string | null, maxWidth = 520): string | null {
const thumb = item.ImageTags?.Thumb;
if (thumb) return imageUrl(item.Id, 'Thumb', token, { tag: thumb, maxWidth });
const backdrop = item.BackdropImageTags?.[0];
if (backdrop) return imageUrl(item.Id, 'Backdrop', token, { tag: backdrop, maxWidth, index: 0 });
const parentBackdrop = item.ParentBackdropImageTags?.[0];
if (parentBackdrop && item.ParentBackdropItemId) {
return imageUrl(item.ParentBackdropItemId, 'Backdrop', token, { tag: parentBackdrop, maxWidth, index: 0 });
}
return posterUrl(item, token, maxWidth);
}
export function backdropUrl(item: JellyItem, token: string | null, maxWidth = 1600): string | null {
const backdrop = item.BackdropImageTags?.[0];
if (backdrop) return imageUrl(item.Id, 'Backdrop', token, { tag: backdrop, maxWidth, index: 0 });
const parentBackdrop = item.ParentBackdropImageTags?.[0];
if (parentBackdrop && item.ParentBackdropItemId) {
return imageUrl(item.ParentBackdropItemId, 'Backdrop', token, { tag: parentBackdrop, maxWidth, index: 0 });
}
return null;
}
// ── Formatting ────────────────────────────────────────────────────────────────────────────────────
export const TICKS_PER_SECOND = 10_000_000;
export const ticksToSeconds = (ticks: number | null | undefined): number => (ticks ? ticks / TICKS_PER_SECOND : 0);
/** Runtime as "1h 42m" / "48m". Blank rather than "0m" when unknown — a wrong number reads as a real one. */
export function formatRuntime(ticks: number | null | undefined): string {
const total = Math.round(ticksToSeconds(ticks) / 60);
if (!total) return '';
const hours = Math.floor(total / 60);
const minutes = total % 60;
return hours ? `${hours}h ${minutes}m` : `${minutes}m`;
}
/** Clock time for the player scrubber — "12:34" under an hour, "1:02:03" over it. */
export function formatClock(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const total = Math.floor(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
export function formatBytes(bytes: number | null | undefined): string {
if (!bytes) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
}
export const formatShortDate = (iso: string | null | undefined): string =>
iso ? new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : '—';
/** How far through an item is, 0–100. Jellyfin sends this only sometimes, so it is also derived. */
export function progressPercent(item: JellyItem): number {
const data = item.UserData;
if (!data) return 0;
if (typeof data.PlayedPercentage === 'number') return Math.min(100, Math.max(0, data.PlayedPercentage));
const position = ticksToSeconds(data.PlaybackPositionTicks);
const runtime = ticksToSeconds(item.RunTimeTicks);
return runtime > 0 && position > 0 ? Math.min(100, (position / runtime) * 100) : 0;
}
/** "S2:E5 · Episode name" for an episode, the plain name for anything else. */
export function episodeLabel(item: JellyItem): string {
if (item.Type !== 'Episode') return item.Name ?? 'Untitled';
const season = item.ParentIndexNumber != null ? `S${item.ParentIndexNumber}` : '';
const episode = item.IndexNumber != null ? `E${item.IndexNumber}` : '';
const number = [season, episode].filter(Boolean).join(':');
return number ? `${number} · ${item.Name ?? ''}`.trim() : (item.Name ?? 'Untitled');
}
/** The line under a title in a grid: the series for an episode, otherwise the year. */
export function subtitleFor(item: JellyItem): string {
if (item.Type === 'Episode') return item.SeriesName ?? '';
if (item.Type === 'Season') return item.SeriesName ?? '';
return item.ProductionYear ? String(item.ProductionYear) : '';
}
/** Which grids get a 2:3 poster and which get a 16:9 still. Episodes are wide; everything else is tall. */
export const isWideItem = (item: JellyItem): boolean => item.Type === 'Episode';