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.
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
import type { JellyfinServerRow } from './useJellyfinData';
|
|
import { useState } from 'react';
|
|
import { Check, CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { formatShortDate } from './shared';
|
|
import {
|
|
jellyfinErrorMessage,
|
|
useJellyfinHealth,
|
|
useJellyfinServerActions,
|
|
useJellyfinServers,
|
|
} from './useJellyfinData';
|
|
|
|
// Connecting Jellyfin servers to Officer, from the app.
|
|
//
|
|
// This screen is BOTH the setup wizard and the permanent settings page: JellyfinView renders it in place of
|
|
// whatever section the URL asks for while nothing is connected, and /jellyfin/settings renders it for good.
|
|
// One component, so a second server added later goes through exactly the code path that stored the first.
|
|
//
|
|
// There is no "paste an API key" alternative here, unlike the Immich screen. Jellyfin's API keys are
|
|
// SERVER-wide and identity-less — they authenticate as nobody, which breaks every per-user route this app
|
|
// depends on (resume points, next up, watched marks). Signing in is the only credential that has a user
|
|
// attached, so it is the only one offered. The password is used once and never stored.
|
|
|
|
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
|
|
|
|
/** Jellyfin's own default port. The sidecar dials from this machine, so localhost is the right default. */
|
|
const DEFAULT_URL = 'http://localhost:8096';
|
|
|
|
const URL_HINT =
|
|
"The server's base URL, without /web. Officer reaches it from the server, not from this browser — so " +
|
|
'localhost here means the machine Officer runs on, and a local instance needs no TLS.';
|
|
|
|
const SIGN_IN_HINT =
|
|
'Your Jellyfin login. It is used once, by the sidecar, to exchange for an access token — the password is ' +
|
|
'never stored and never reaches Postgres. The session shows up in Jellyfin under Devices as "Officer"; ' +
|
|
'signing it out there is what revokes this connection.';
|
|
|
|
type FieldProps = {
|
|
label: string;
|
|
hint?: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder: string;
|
|
type?: string;
|
|
autoFocus?: boolean;
|
|
};
|
|
|
|
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
|
|
<label className="flex flex-col gap-1.5">
|
|
<span className="text-xs font-medium">{label}</span>
|
|
<Input
|
|
value={value}
|
|
onChange={(ev) => onChange(ev.target.value)}
|
|
placeholder={placeholder}
|
|
type={type}
|
|
autoFocus={autoFocus}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
/>
|
|
{hint && <span className={HINT}>{hint}</span>}
|
|
</label>
|
|
);
|
|
|
|
function useCredential() {
|
|
const [username, setUsername] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
|
|
const filled = !!username.trim() && !!password;
|
|
const reset = () => {
|
|
setUsername('');
|
|
setPassword('');
|
|
};
|
|
|
|
return { username, setUsername, password, setPassword, filled, reset };
|
|
}
|
|
|
|
type CredentialState = ReturnType<typeof useCredential>;
|
|
|
|
const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => (
|
|
<>
|
|
<Field
|
|
label="Username"
|
|
value={cred.username}
|
|
onChange={cred.setUsername}
|
|
placeholder="your Jellyfin user"
|
|
autoFocus={autoFocus}
|
|
/>
|
|
<Field
|
|
label="Password"
|
|
value={cred.password}
|
|
onChange={cred.setPassword}
|
|
placeholder="••••••••"
|
|
type="password"
|
|
hint={SIGN_IN_HINT}
|
|
/>
|
|
</>
|
|
);
|
|
|
|
/**
|
|
* One stored server. The active one carries the live health line, because health only ever describes the
|
|
* server actually in use — showing a status next to the others would be inventing one. "Test" on an inactive
|
|
* row probes it directly instead, which is the only way to check a server without switching to it first.
|
|
*/
|
|
const ServerRow = ({ server }: { server: JellyfinServerRow }) => {
|
|
const { data: health, refetch: recheck, isFetching: checking } = useJellyfinHealth();
|
|
const { edit, activate, remove, test } = useJellyfinServerActions();
|
|
|
|
const cred = useCredential();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [replacing, setReplacing] = useState(false);
|
|
// Removing a server throws away a token Officer can never show again, so the bin asks once.
|
|
const [confirming, setConfirming] = useState(false);
|
|
|
|
const run = async (action: Promise<unknown>) => {
|
|
setError(null);
|
|
try {
|
|
await action;
|
|
cred.reset();
|
|
setReplacing(false);
|
|
} catch (err) {
|
|
setError(jellyfinErrorMessage(err));
|
|
}
|
|
};
|
|
|
|
const busy = checking || test.isPending;
|
|
|
|
return (
|
|
<div className={`flex flex-col gap-2 rounded-lg border p-4 text-xs ${server.isActive ? 'border-primary/40' : ''}`}>
|
|
<div className="flex items-center gap-2">
|
|
{server.isActive ? <Check className="h-4 w-4 shrink-0 text-primary" /> : <span className="h-4 w-4 shrink-0" />}
|
|
<span className="truncate font-medium">{server.label}</span>
|
|
{server.isActive && (
|
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary">in use</span>
|
|
)}
|
|
<div className="ml-auto flex items-center gap-1">
|
|
{!server.isActive && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7"
|
|
disabled={activate.isPending}
|
|
onClick={() => void run(activate.mutateAsync(server.id))}
|
|
>
|
|
Use
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7"
|
|
disabled={busy}
|
|
onClick={() => void (server.isActive ? recheck() : run(test.mutateAsync(server.id)))}
|
|
>
|
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Test'}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className={`h-7 ${confirming ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}`}
|
|
disabled={remove.isPending}
|
|
onClick={() => {
|
|
if (!confirming) return setConfirming(true);
|
|
setConfirming(false);
|
|
void run(remove.mutateAsync(server.id));
|
|
}}
|
|
>
|
|
{confirming ? 'Remove?' : <Trash2 className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="truncate text-muted-foreground">
|
|
{server.url}
|
|
{server.jellyfinUsername ? ` · ${server.jellyfinUsername}` : ''}
|
|
</p>
|
|
|
|
{server.isActive ? (
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
{health?.ok ? (
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
|
|
) : (
|
|
<TriangleAlert className="h-3.5 w-3.5 text-warning" />
|
|
)}
|
|
<span>
|
|
{health?.ok
|
|
? `Jellyfin ${health.version ?? '?'}${health.serverName ? ` · ${health.serverName}` : ''}`
|
|
: (health?.error ?? 'not checked yet')}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<p className="text-muted-foreground">
|
|
{server.version ? `Jellyfin ${server.version}` : 'version unknown'}
|
|
{server.lastSeenAt ? ` · last answered ${formatShortDate(server.lastSeenAt)}` : ''}
|
|
</p>
|
|
)}
|
|
|
|
{replacing ? (
|
|
<div className="flex flex-col gap-3 rounded-lg border p-3">
|
|
<CredentialFields cred={cred} autoFocus />
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
className="h-8"
|
|
disabled={!cred.filled || edit.isPending}
|
|
onClick={() =>
|
|
void run(edit.mutateAsync({ id: server.id, username: cred.username.trim(), password: cred.password }))
|
|
}
|
|
>
|
|
{edit.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
|
Save
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={() => {
|
|
cred.reset();
|
|
setReplacing(false);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setReplacing(true)}
|
|
className="self-start text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
|
>
|
|
Sign in again
|
|
</button>
|
|
)}
|
|
|
|
{error && <p className="text-destructive">{error}</p>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const ConnectionSection = () => {
|
|
const { data, isLoading } = useJellyfinServers();
|
|
const { add } = useJellyfinServerActions();
|
|
|
|
const cred = useCredential();
|
|
const [label, setLabel] = useState('');
|
|
const [url, setUrl] = useState(DEFAULT_URL);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const servers = data?.servers ?? [];
|
|
const hasServers = servers.length > 0;
|
|
|
|
const submit = async () => {
|
|
setError(null);
|
|
if (!url.trim()) return setError('The Jellyfin URL is required');
|
|
if (!cred.filled) return setError('A username and password are required');
|
|
|
|
try {
|
|
await add.mutateAsync({
|
|
label: label.trim(),
|
|
url: url.trim(),
|
|
username: cred.username.trim(),
|
|
password: cred.password,
|
|
});
|
|
setLabel('');
|
|
setUrl(DEFAULT_URL);
|
|
cred.reset();
|
|
} catch (err) {
|
|
setError(jellyfinErrorMessage(err));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto">
|
|
<div className="mx-auto flex max-w-xl flex-col gap-4 p-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
|
<Plug className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-sm font-semibold">{hasServers ? 'Jellyfin servers' : 'Connect Jellyfin'}</h2>
|
|
<p className="text-xs text-muted-foreground">
|
|
{hasServers
|
|
? 'Officer stores each server and its access token encrypted, for this account only. One is in use at a time.'
|
|
: 'Video needs a Jellyfin server and a login before it can show anything.'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{!isLoading && servers.map((server) => <ServerRow key={server.id} server={server} />)}
|
|
|
|
<form
|
|
onSubmit={(ev) => {
|
|
ev.preventDefault();
|
|
void submit();
|
|
}}
|
|
className="flex flex-col gap-3 rounded-lg border p-4"
|
|
>
|
|
<p className="text-xs font-medium">{hasServers ? 'Add another server' : 'Add a server'}</p>
|
|
<Field
|
|
label="Label"
|
|
value={label}
|
|
onChange={setLabel}
|
|
placeholder="Optional — defaults to the server's own name"
|
|
hint="What the switcher calls this server. Two servers cannot share a label; two can share a URL."
|
|
autoFocus={hasServers}
|
|
/>
|
|
<Field
|
|
label="Jellyfin URL"
|
|
value={url}
|
|
onChange={setUrl}
|
|
placeholder={DEFAULT_URL}
|
|
hint={URL_HINT}
|
|
autoFocus={!hasServers}
|
|
/>
|
|
<CredentialFields cred={cred} />
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button type="submit" size="sm" disabled={add.isPending}>
|
|
{add.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
|
{add.isPending ? 'Verifying…' : hasServers ? 'Add server' : 'Connect'}
|
|
</Button>
|
|
{add.isPending && <span className={HINT}>Checking the server and signing in…</span>}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|