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.
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import type { HomeResponse, ItemsResponse, JellyItem, PlaybackPlan } from './shared';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useClient } from 'hooks/useClient';
|
|
|
|
// Reads for the /jellyfin panels. Everything goes through /api/jellyfin/_officer/*, the auth proxy in front
|
|
// of the officer-jellyfin sidecar — the browser never learns the Jellyfin URL or its access token.
|
|
//
|
|
// One query key namespace, ['jellyfin'], and every mutation invalidates all of it. That is coarse on purpose:
|
|
// marking an episode watched changes Continue Watching, Next Up, the season list and the item itself, and
|
|
// enumerating those relationships would be four chances to forget one.
|
|
|
|
const KEY = 'jellyfin';
|
|
|
|
const query = (params: Record<string, string | number | boolean | null | undefined>): string => {
|
|
const search = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value !== undefined && value !== null && value !== '') search.set(key, String(value));
|
|
}
|
|
const qs = search.toString();
|
|
return qs ? `?${qs}` : '';
|
|
};
|
|
|
|
/** Libraries, Continue Watching, Next Up and the newest thing per library — one round trip. */
|
|
export function useJellyfinHome(enabled = true) {
|
|
const { get } = useClient();
|
|
return useQuery({
|
|
queryKey: [KEY, 'home'],
|
|
queryFn: () => get<HomeResponse>('/jellyfin/_officer/home'),
|
|
enabled,
|
|
staleTime: 30_000,
|
|
});
|
|
}
|
|
|
|
/** Just the libraries — what the nav lists. Cheap enough to keep separate from the whole home payload. */
|
|
export function useJellyfinViews(enabled = true) {
|
|
const { get } = useClient();
|
|
return useQuery({
|
|
queryKey: [KEY, 'views'],
|
|
queryFn: () => get<{ views: JellyItem[] }>('/jellyfin/_officer/views'),
|
|
enabled,
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
}
|
|
|
|
export type BrowseFilter = {
|
|
parentId?: string;
|
|
includeItemTypes?: string;
|
|
sortBy?: string;
|
|
sortOrder?: 'Ascending' | 'Descending';
|
|
startIndex?: number;
|
|
limit?: number;
|
|
filters?: string;
|
|
isFavorite?: boolean;
|
|
recursive?: boolean;
|
|
collapseBoxSetItems?: boolean;
|
|
searchTerm?: string;
|
|
};
|
|
|
|
export function useJellyfinItems(filter: BrowseFilter, enabled = true) {
|
|
const { get } = useClient();
|
|
const qs = query(filter);
|
|
return useQuery({
|
|
queryKey: [KEY, 'items', qs],
|
|
queryFn: () => get<ItemsResponse>(`/jellyfin/_officer/items${qs}`),
|
|
enabled,
|
|
staleTime: 30_000,
|
|
});
|
|
}
|
|
|
|
export function useJellyfinItem(id: string | null) {
|
|
const { get } = useClient();
|
|
return useQuery({
|
|
queryKey: [KEY, 'item', id],
|
|
queryFn: () => get<JellyItem>(`/jellyfin/_officer/items/${id}`),
|
|
enabled: !!id,
|
|
staleTime: 30_000,
|
|
});
|
|
}
|
|
|
|
export function useSeasons(seriesId: string | null) {
|
|
const { get } = useClient();
|
|
return useQuery({
|
|
queryKey: [KEY, 'seasons', seriesId],
|
|
queryFn: () => get<ItemsResponse>(`/jellyfin/_officer/shows/${seriesId}/seasons`),
|
|
enabled: !!seriesId,
|
|
staleTime: 60_000,
|
|
});
|
|
}
|
|
|
|
export function useEpisodes(seriesId: string | null, seasonId: string | null) {
|
|
const { get } = useClient();
|
|
return useQuery({
|
|
queryKey: [KEY, 'episodes', seriesId, seasonId],
|
|
queryFn: () => get<ItemsResponse>(`/jellyfin/_officer/shows/${seriesId}/episodes${query({ seasonId })}`),
|
|
enabled: !!seriesId && !!seasonId,
|
|
staleTime: 60_000,
|
|
});
|
|
}
|
|
|
|
export function useJellyfinSearch(term: string, enabled = true) {
|
|
const { get } = useClient();
|
|
const trimmed = term.trim();
|
|
return useQuery({
|
|
queryKey: [KEY, 'search', trimmed],
|
|
queryFn: () => get<ItemsResponse>(`/jellyfin/_officer/search${query({ q: trimmed })}`),
|
|
enabled: enabled && trimmed.length > 1,
|
|
staleTime: 30_000,
|
|
});
|
|
}
|
|
|
|
/** Watched and favourite marks. Both invalidate everything, because both change Next Up and Resume. */
|
|
export function useItemFlags() {
|
|
const { post, delete: del } = useClient();
|
|
const qc = useQueryClient();
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: [KEY] });
|
|
|
|
const setPlayed = useMutation({
|
|
mutationFn: ({ id, played }: { id: string; played: boolean }) =>
|
|
played ? post(`/jellyfin/_officer/items/${id}/played`, {}) : del(`/jellyfin/_officer/items/${id}/played`),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const setFavorite = useMutation({
|
|
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
|
|
favorite ? post(`/jellyfin/_officer/items/${id}/favorite`, {}) : del(`/jellyfin/_officer/items/${id}/favorite`),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
return { setPlayed, setFavorite };
|
|
}
|
|
|
|
// ── Playback ──────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
export type PlaybackRequest = {
|
|
id: string;
|
|
startSeconds: number;
|
|
mediaSourceId?: string;
|
|
audioStreamIndex?: number;
|
|
/** A rung the owner picked. Omitted means Auto — the server decides whether it can copy the video. */
|
|
maxStreamingBitrate?: number;
|
|
};
|
|
|
|
/**
|
|
* Negotiate a stream. A mutation rather than a query on purpose: it has a side effect on the server —
|
|
* `AutoOpenLiveStream` starts an ffmpeg process for a transcode — so it must never be replayed by a refetch,
|
|
* a window focus, or React's strict-mode double render.
|
|
*/
|
|
export function usePlaybackPlan() {
|
|
const { post } = useClient();
|
|
return useMutation({
|
|
mutationFn: ({ id, startSeconds, mediaSourceId, audioStreamIndex, maxStreamingBitrate }: PlaybackRequest) =>
|
|
post<PlaybackPlan>(`/jellyfin/_officer/items/${id}/playback${query({ startSeconds })}`, {
|
|
mediaSourceId,
|
|
audioStreamIndex,
|
|
maxStreamingBitrate,
|
|
}),
|
|
});
|
|
}
|
|
|
|
export type PlaystateReport = {
|
|
ItemId: string;
|
|
PlaySessionId?: string | null;
|
|
MediaSourceId?: string | null;
|
|
PositionTicks: number;
|
|
IsPaused?: boolean;
|
|
CanSeek?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Tell Jellyfin where playback is, so resume works and the server knows a session is alive.
|
|
*
|
|
* `fire-and-forget` is deliberate: a dropped progress ping costs a few seconds of resume accuracy, and
|
|
* surfacing it as an error over the video would be a worse trade. The stop report is the one that matters,
|
|
* and it is also the one most likely to race a page unload — hence `sendBeacon`-shaped semantics (no
|
|
* response read, no await from the caller).
|
|
*/
|
|
export function usePlaystateReporter() {
|
|
const { post } = useClient();
|
|
const qc = useQueryClient();
|
|
|
|
const report = (action: 'playing' | 'progress' | 'stopped', body: PlaystateReport) => {
|
|
void post(`/jellyfin/_officer/sessions/${action}`, body).catch(() => undefined);
|
|
// Only a stop changes what the shelves should show. Pinging progress every ten seconds and invalidating
|
|
// each time would refetch the whole home screen underneath a playing video.
|
|
if (action === 'stopped') void qc.invalidateQueries({ queryKey: [KEY] });
|
|
};
|
|
|
|
return report;
|
|
}
|
|
|
|
// ── Servers ───────────────────────────────────────────────────────────────────────────────────────
|
|
//
|
|
// Jellyfin instances and the account each is signed in as are the owner's to set from /jellyfin/settings.
|
|
// It is a REGISTRY — any number of labelled servers, one selected — because this machine genuinely runs
|
|
// several. The access token is WRITE-ONLY across this boundary: a server carries its label, URL, account name
|
|
// and whether it is selected, and has no field that could carry the token back to the browser.
|
|
|
|
export type JellyfinHealth = {
|
|
ok: boolean;
|
|
/** False only when no server is stored. It is what separates "set this up" from "this used to work". */
|
|
configured?: boolean;
|
|
/** Label of the selected server, so a failure names which one did not answer. */
|
|
server?: string;
|
|
serverName?: string | null;
|
|
version?: string | null;
|
|
ms?: number;
|
|
error?: string;
|
|
};
|
|
|
|
export type JellyfinServerRow = {
|
|
id: number;
|
|
label: string;
|
|
url: string;
|
|
jellyfinUsername: string | null;
|
|
serverName: string | null;
|
|
version: string | null;
|
|
isActive: boolean;
|
|
lastSeenAt: string | null;
|
|
createdAt: string;
|
|
};
|
|
|
|
export type JellyfinServers = { configured: boolean; activeId: number | null; servers: JellyfinServerRow[] };
|
|
|
|
/** Unwrap the `{ status, message }` useClient throws, where `message` is the sidecar's JSON body. */
|
|
export function jellyfinErrorMessage(err: unknown): string {
|
|
const raw = (err as { message?: unknown } | null)?.message;
|
|
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
|
|
try {
|
|
const parsed = JSON.parse(raw) as { error?: unknown };
|
|
if (typeof parsed.error === 'string' && parsed.error) return parsed.error;
|
|
} catch {
|
|
/* plain text */
|
|
}
|
|
return raw.slice(0, 300);
|
|
}
|
|
|
|
/**
|
|
* Health, including its failure bodies.
|
|
*
|
|
* `get` throws on any status >= 400, so a plain query would leave `data` undefined for exactly the two cases
|
|
* the UI most needs to tell apart — 503 not configured and 502 configured-but-broken. Both carry a JSON body,
|
|
* so the throw is turned back into the answer rather than an error state.
|
|
*/
|
|
export function useJellyfinHealth() {
|
|
const { get } = useClient();
|
|
return useQuery({
|
|
queryKey: [KEY, 'health'],
|
|
queryFn: async (): Promise<JellyfinHealth> => {
|
|
try {
|
|
return await get<JellyfinHealth>('/jellyfin/_health');
|
|
} catch (err) {
|
|
const raw = (err as { message?: unknown } | null)?.message;
|
|
if (typeof raw === 'string') {
|
|
try {
|
|
const body = JSON.parse(raw) as JellyfinHealth;
|
|
if (body && body.ok === false) return body;
|
|
} catch {
|
|
/* not the sidecar's body */
|
|
}
|
|
}
|
|
// Anything else — the platform proxy, auth, the sidecar being down — is a configured server that is
|
|
// failing, not an unconfigured one. Never offer the setup form on a guess.
|
|
return { ok: false, configured: true, error: jellyfinErrorMessage(err) };
|
|
}
|
|
},
|
|
staleTime: 60_000,
|
|
retry: false,
|
|
});
|
|
}
|
|
|
|
export function useJellyfinServers() {
|
|
const { get } = useClient();
|
|
return useQuery({
|
|
queryKey: [KEY, 'servers'],
|
|
queryFn: () => get<JellyfinServers>('/jellyfin/_config'),
|
|
staleTime: 60_000,
|
|
retry: false,
|
|
});
|
|
}
|
|
|
|
export type AddServerInput = { label: string; url: string; username: string; password: string };
|
|
export type EditServerInput = { id: number; label?: string; url?: string; username?: string; password?: string };
|
|
|
|
export function useJellyfinServerActions() {
|
|
const { get, post, patch, delete: del } = useClient();
|
|
const qc = useQueryClient();
|
|
// Adding, editing, switching and removing all change what every other query here can even answer — a switch
|
|
// in particular changes the answer to all of them without changing any of their inputs.
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: [KEY] });
|
|
|
|
const add = useMutation({
|
|
mutationFn: (input: AddServerInput) => post<{ server: JellyfinServerRow }>('/jellyfin/_config', input),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const edit = useMutation({
|
|
mutationFn: ({ id, ...rest }: EditServerInput) =>
|
|
patch<{ server: JellyfinServerRow }>(`/jellyfin/_config/${id}`, rest),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const activate = useMutation({
|
|
mutationFn: (id: number) => post<JellyfinServers>(`/jellyfin/_config/${id}/activate`, {}),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (id: number) => del<JellyfinServers>(`/jellyfin/_config/${id}`),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
// GET — probes one server and reports whether it answered. It does not switch to it (that is
|
|
// `activate`) and writes nothing. useMutation is still right: it is fired by a button, not rendered.
|
|
const test = useMutation({
|
|
mutationFn: (id: number) =>
|
|
get<{ ok: boolean; version?: string | null; serverName?: string | null; ms: number }>(
|
|
`/jellyfin/_config/${id}/test`,
|
|
),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
return { add, edit, activate, remove, test };
|
|
}
|