Files
jellyfin/web/useJellyfinData.ts
T
pastilhasandClaude Opus 5 e8aa85a35b settings: show who exists on each side, and which pairs are the same person
Provisioning was invisible. Connecting a server can create accounts, adopt accounts and leave
accounts alone, and the only record of which happened was a JSON field in a response nobody reads and
rows in a table nobody can see. "Did it work" could only be answered by opening Jellyfin and counting.

The nav entry is Settings rather than Connection, since it is now more than the connection.

The join is computed in the sidecar and shares `normalize` with the reconcile planner, so the screen
cannot disagree with the matching rule about whether Diogo and diogo are one person — a settings page
saying two would be lying about what the next connection will do. It joins by the link ROW first and
by folded name only where none exists: a recorded pairing is a fact, a matching name is a candidate.

Four states, because they mean different things — linked (adopted or created), names match but not
recorded yet, Officer account with no Jellyfin one, and a Jellyfin account with no Officer user. The
last is never touched, and a linked pairing whose Jellyfin account has vanished is shown as such
rather than silently re-matched by name.

"Not known" is kept distinct from "none": if the platform has not sent the account list, or the
server cannot be reached, it says so instead of rendering an empty table that would read as a fact
about the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:01:43 +00:00

353 lines
13 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 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;
};
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<AccountView>(`/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<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 };
}