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 => { 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('/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(`/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(`/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(`/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(`/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(`/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(`/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 => { try { return await get('/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('/jellyfin/_config'), staleTime: 60_000, retry: false, }); } export type AccountPair = { officer: { id: number; username: string | null; name: string | null; role: string } | null; jellyfin: { id: string; name: string } | null; linked: boolean; adopted: boolean; matchable: boolean; isConnection: boolean; }; export type AccountView = | { known: false; reason: string } | { known: true; pairs: AccountPair[]; counts: { linked: number; officerOnly: number; jellyfinOnly: number } }; /** * Who exists on each side of one server, and which pairs are the same person. * * Not cached beyond a few seconds: the reason to look at this is usually that somebody changed something * in Jellyfin's own UI, and a stale answer is the one failure this screen exists to prevent. */ export function useJellyfinAccounts(serverId: number | null) { const { get } = useClient(); return useQuery({ queryKey: [KEY, 'accounts', serverId], queryFn: () => get(`/jellyfin/_config/${serverId}/users`), enabled: serverId !== null, staleTime: 5_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(`/jellyfin/_config/${id}/activate`, {}), onSuccess: invalidate, }); const remove = useMutation({ mutationFn: (id: number) => del(`/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 }; }