import type { SessionSettings, SessionStats, Torrent, TorrentAction } from './shared'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useClient } from 'hooks/useClient'; // Data layer for the /transmission panels. Everything talks to the officer-transmission sidecar through the // /api/transmission auth proxy. // // Transmission has no push channel — it is poll-only RPC — so the list refetches on an interval rather than // subscribing. Mutations invalidate the whole ['transmission'] prefix instead of patching caches: starting // one torrent changes the session speed totals and the queue positions of every torrent below it, so a // targeted patch would be wrong more often than it was cheap. const TORRENTS_KEY = ['transmission', 'torrents'] as const; const SESSION_KEY = ['transmission', 'session'] as const; const STATS_KEY = ['transmission', 'stats'] as const; const EMPTY_TORRENTS: Torrent[] = []; /** How often the list re-reads. Matches the reference client's default and is cheap against loopback. */ const LIST_POLL_MS = 5_000; /** Session settings change only when someone edits them, so this is a safety net, not a live feed. */ const SESSION_POLL_MS = 60_000; export function useTorrents() { const { get } = useClient(); const query = useQuery({ queryKey: TORRENTS_KEY, queryFn: () => get<{ torrents: Torrent[] }>('/transmission/_officer/torrents'), refetchInterval: LIST_POLL_MS, // Anything shorter than the poll would make an unrelated remount trigger a second fetch immediately. staleTime: LIST_POLL_MS - 1_000, }); return { torrents: query.data?.torrents ?? EMPTY_TORRENTS, isLoading: query.isLoading, error: query.error, }; } /** * One torrent with the expensive arrays (files, peers, trackers). Kept separate from the list because those * arrays are per-file and per-peer — pulling them for every row on every poll is what makes naive * Transmission clients hammer the daemon. */ export function useTorrentDetail(id: number | null) { const { get } = useClient(); const query = useQuery({ queryKey: ['transmission', 'torrent', id] as const, queryFn: () => get<{ torrent: Torrent }>(`/transmission/_officer/torrents/${id}`), enabled: id != null, refetchInterval: LIST_POLL_MS, staleTime: LIST_POLL_MS - 1_000, }); return { torrent: query.data?.torrent ?? null, isLoading: query.isLoading, error: query.error }; } export function useSessionStats() { const { get } = useClient(); const query = useQuery({ queryKey: STATS_KEY, queryFn: () => get<{ stats: SessionStats }>('/transmission/_officer/stats'), refetchInterval: LIST_POLL_MS, staleTime: LIST_POLL_MS - 1_000, }); return { stats: query.data?.stats ?? null, isLoading: query.isLoading, error: query.error }; } export function useSession() { const { get, post } = useClient(); const qc = useQueryClient(); const query = useQuery({ queryKey: SESSION_KEY, queryFn: () => get<{ session: SessionSettings }>('/transmission/_officer/session'), refetchInterval: SESSION_POLL_MS, staleTime: 30_000, }); const save = useMutation({ mutationFn: (patch: Record) => post<{ session: SessionSettings }>('/transmission/_officer/session', patch), onSuccess: (data) => { // The sidecar reads the settings back after writing, because Transmission clamps several of them. // Seeding the cache with that response means the form shows what the daemon actually holds. qc.setQueryData(SESSION_KEY, data); toast.success('Settings saved'); }, onError: (err) => toast.error(errorMessage(err, 'Could not save settings')), }); return { session: query.data?.session ?? null, isLoading: query.isLoading, error: query.error, save, }; } /** * Free space at a path, as the daemon sees it. Worth stating plainly: this is the *daemon's* filesystem, * which for a containerised Transmission is not the same namespace as the platform's — the number is * meaningful, the path may not resolve to anything on this host. */ export function useFreeSpace(path: string | undefined) { const { get } = useClient(); const query = useQuery({ queryKey: ['transmission', 'free-space', path] as const, queryFn: () => get<{ path: string; bytes: number }>(`/transmission/_officer/free-space?path=${encodeURIComponent(path!)}`), enabled: !!path, staleTime: 60_000, }); return { bytes: query.data?.bytes ?? null, isLoading: query.isLoading, error: query.error }; } export type AddTorrentInput = { metainfo?: string; filename?: string; downloadDir?: string; labels?: string[]; paused?: boolean; bandwidthPriority?: number; sequentialDownload?: boolean; }; export type AddTorrentResult = { status: 'added' | 'duplicate'; torrent: { id: number; name: string } | null }; export function useTorrentMutations() { const { post } = useClient(); const qc = useQueryClient(); const invalidate = () => qc.invalidateQueries({ queryKey: ['transmission'] }); const act = useMutation({ mutationFn: ({ ids, action }: { ids: number[]; action: TorrentAction }) => post<{ ok: true; affected: number }>('/transmission/_officer/torrents/action', { ids, action }), onSuccess: invalidate, onError: (err) => toast.error(errorMessage(err, 'Action failed')), }); const set = useMutation({ mutationFn: ({ ids, ...fields }: { ids: number[] } & Record) => post<{ ok: true }>('/transmission/_officer/torrents/set', { ids, ...fields }), onSuccess: invalidate, onError: (err) => toast.error(errorMessage(err, 'Could not apply the change')), }); const remove = useMutation({ mutationFn: ({ ids, deleteLocalData }: { ids: number[]; deleteLocalData: boolean }) => post<{ ok: true; affected: number }>('/transmission/_officer/torrents/remove', { ids, deleteLocalData }), onSuccess: (data, vars) => { invalidate(); toast.success( `Removed ${vars.ids.length} torrent${vars.ids.length === 1 ? '' : 's'}${ vars.deleteLocalData ? ' and their data' : '' }`, ); }, onError: (err) => toast.error(errorMessage(err, 'Could not remove')), }); const setLocation = useMutation({ mutationFn: ({ ids, location, move }: { ids: number[]; location: string; move: boolean }) => post<{ ok: true }>('/transmission/_officer/torrents/location', { ids, location, move }), onSuccess: () => { invalidate(); toast.success('Location updated'); }, onError: (err) => toast.error(errorMessage(err, 'Could not change location')), }); const rename = useMutation({ mutationFn: ({ id, path, name }: { id: number; path: string; name: string }) => post<{ ok: true }>('/transmission/_officer/torrents/rename', { id, path, name }), onSuccess: () => { invalidate(); toast.success('Renamed'); }, onError: (err) => toast.error(errorMessage(err, 'Could not rename')), }); const add = useMutation({ mutationFn: (input: AddTorrentInput) => post('/transmission/_officer/torrents/add', input), onSuccess: invalidate, onError: (err) => toast.error(errorMessage(err, 'Could not add torrent')), }); return { act, set, remove, setLocation, rename, add }; } export function useMaintenance() { const { get, post } = useClient(); const qc = useQueryClient(); const portTest = useMutation({ // GET — it asks the daemon a question and changes nothing on either side. Still a useMutation // because it is fired by a button rather than rendered from cache: that is a UI concern, not an // HTTP one. blocklistUpdate below stays POST, because it really does refetch and replace the list. mutationFn: () => get<{ open: boolean }>('/transmission/_officer/port-test'), onSuccess: (data) => (data.open ? toast.success('Peer port is open') : toast.error('Peer port is closed')), onError: (err) => toast.error(errorMessage(err, 'Port test failed')), }); const updateBlocklist = useMutation({ mutationFn: () => post<{ size: number }>('/transmission/_officer/blocklist-update'), onSuccess: (data) => { qc.invalidateQueries({ queryKey: SESSION_KEY }); toast.success(`Blocklist updated — ${data.size.toLocaleString()} rules`); }, onError: (err) => toast.error(errorMessage(err, 'Blocklist update failed')), }); return { portTest, updateBlocklist }; } /** * useClient rejects with `{status, message}` rather than an Error, and the sidecar's message is a JSON body. * Unwrap both so the toast says "transmission upstream unreachable" instead of "[object Object]". */ function errorMessage(err: unknown, fallback: string): string { const raw = typeof err === 'object' && err !== null && 'message' in err ? String(err.message) : ''; if (!raw) return fallback; try { const parsed = JSON.parse(raw) as { error?: string }; if (parsed.error) return parsed.error; } catch { /* not JSON — use it as-is */ } return raw || fallback; }