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>
239 lines
8.9 KiB
TypeScript
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;
|
|
}
|