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 { AccountsSection } from './AccountsSection'; 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) => ( ); 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; const CredentialFields = ({ cred, autoFocus }: { cred: CredentialState; autoFocus?: boolean }) => ( <> ); /** * 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(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) => { setError(null); try { await action; cred.reset(); setReplacing(false); } catch (err) { setError(jellyfinErrorMessage(err)); } }; const busy = checking || test.isPending; return (
{server.isActive ? : } {server.label} {server.isActive && ( in use )}
{!server.isActive && ( )}

{server.url} {server.jellyfinUsername ? ` · ${server.jellyfinUsername}` : ''}

{server.isActive ? (
{health?.ok ? ( ) : ( )} {health?.ok ? `Jellyfin ${health.version ?? '?'}${health.serverName ? ` · ${health.serverName}` : ''}` : (health?.error ?? 'not checked yet')}
) : (

{server.version ? `Jellyfin ${server.version}` : 'version unknown'} {server.lastSeenAt ? ` · last answered ${formatShortDate(server.lastSeenAt)}` : ''}

)} {replacing ? (
) : ( )} {error &&

{error}

}
); }; 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(null); const servers = data?.servers ?? []; const hasServers = servers.length > 0; // The active one, because the accounts view is per server and "which server" is already a decided // question here — the switcher owns it, and asking again on this screen would be a second answer. const activeServer = servers.find((s) => s.isActive) ?? servers[0] ?? null; 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 (

{hasServers ? 'Jellyfin servers' : 'Connect Jellyfin'}

{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.'}

{!isLoading && servers.map((server) => )}
{ ev.preventDefault(); void submit(); }} className="flex flex-col gap-3 rounded-lg border p-4" >

{hasServers ? 'Add another server' : 'Add a server'}

{error &&

{error}

}
{add.isPending && Checking the server and signing in…}
{/* Below the form on purpose: connecting is what CHANGES this, so the thing you act on comes first and the consequence is read underneath it. */}
); };