Files
transmission/web/useTransmissionData.ts
pastilhasandClaude Opus 5 864dd16355 transmission, extracted from the platform into its own repository
The sixth plugin out, immediately after photos. A sidecar, a proxy router and
29 web files; no db/ directory, because there are no tables.

── The one thing that is not self-contained ──

The connection lives in `service_connections`, a CORE table, reached through
getServiceConnection/saveServiceConnection/getServiceCredentials. This plugin
declares no schema.

Deliberate: that table is the app store's shared credential store, keyed on
(user_id, service), already encrypted at rest and already read by
app-store/effects.ts. A private copy of the same three columns would be a
second place the same fact could live, and the runbook is explicit that a move
plus a data-format change gives any failure two causes.

The cost, stated rather than hidden: this plugin depends on a core facility it
does not own, and would break if that table changed shape. Photos, by contrast,
owns photos_config outright.

── 38 palette classes paid down, mapped by meaning ──

  blue     downloading / download speed  -> info
  cyan     checking (verifying)          -> busy
  emerald  seeding / complete            -> success
  amber    queued / stalled / warnings   -> warning

Collapsing blue and cyan into one tone would have lost a real distinction in
the status dot, so `checking` went to `busy` — in-progress work, which is what
that tone is for.

── A near-miss worth recording ──

The first copy globbed *.tsx and *.ts and silently missed detail/ and dialogs/
— ten components. tsgo caught it as ten "cannot find module" errors. Worth
stating because it is quiet in the other direction: had those been imported
lazily, the typecheck would have passed and the panels would have broken at
runtime. Check the directory tree, not the file glob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:19:30 +00:00

239 lines
8.9 KiB
TypeScript

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<string, unknown>) =>
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<string, unknown>) =>
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<AddTorrentResult>('/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;
}