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>
This commit is contained in:
2026-08-18 19:19:30 +00:00
co-authored by Claude Opus 5
commit 864dd16355
40 changed files with 5656 additions and 0 deletions
+245
View File
@@ -0,0 +1,245 @@
import type { Torrent } from '../shared';
import { useMemo, useState } from 'react';
import { ChevronRight, File, Folder, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { PRIORITY } from '../shared';
import { formatPercent, formatSize, PRIORITY_LABELS } from '../format';
import { useTorrentMutations } from '../useTransmissionData';
import { RenameDialog } from '../dialogs/RenameDialog';
// Files, as a tree, because a torrent of a TV season is thirty files in three folders and a flat list of
// thirty identical-looking paths is unusable.
//
// Transmission addresses files by their INDEX in the `files` array, not by path — so every node carries the
// indices beneath it, and a folder operation is just the same RPC with more indices.
type FileNode = {
key: string;
name: string;
/** Leaf files only; a directory's own index list is the union of its descendants'. */
index?: number;
children: FileNode[];
indices: number[];
size: number;
completed: number;
};
function buildTree(torrent: Torrent): FileNode {
const root: FileNode = { key: '', name: '', children: [], indices: [], size: 0, completed: 0 };
const files = torrent.files ?? [];
files.forEach((file, index) => {
const segments = file.name.split('/').filter(Boolean);
let node = root;
segments.forEach((segment, depth) => {
const isLeaf = depth === segments.length - 1;
const key = segments.slice(0, depth + 1).join('/');
let child = node.children.find((c) => c.key === key);
if (!child) {
child = { key, name: segment, children: [], indices: [], size: 0, completed: 0 };
node.children.push(child);
}
if (isLeaf) child.index = index;
node = child;
});
});
// Roll sizes and indices up in one post-order pass, so a folder row can show its own progress bar.
const roll = (node: FileNode): FileNode => {
if (node.index != null) {
const file = files[node.index]!;
node.size = file.length;
node.completed = file.bytesCompleted;
node.indices = [node.index];
return node;
}
node.children.forEach(roll);
node.size = node.children.reduce((s, c) => s + c.size, 0);
node.completed = node.children.reduce((s, c) => s + c.completed, 0);
node.indices = node.children.flatMap((c) => c.indices);
// Folders before files, then alphabetical — the ordering every file manager uses.
node.children.sort((a, b) => Number(!!a.index) - Number(!!b.index) || a.name.localeCompare(b.name));
return node;
};
return roll(root);
}
type FilesTabProps = { torrent: Torrent };
export const FilesTab = ({ torrent }: FilesTabProps) => {
const { set } = useTorrentMutations();
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [renaming, setRenaming] = useState<string | null>(null);
const tree = useMemo(() => buildTree(torrent), [torrent.files, torrent.id]);
const stats = torrent.fileStats ?? [];
if (!torrent.files?.length) {
return (
<p className="p-6 text-center text-sm text-muted-foreground">
{torrent.metadataPercentComplete < 1
? 'Still fetching metadata — the file list appears once the magnet resolves.'
: 'This torrent has no files.'}
</p>
);
}
const wantedCount = stats.filter((s) => s.wanted).length;
const wantedBytes = torrent.files.reduce((sum, f, i) => sum + (stats[i]?.wanted === false ? 0 : f.length), 0);
const setWanted = (indices: number[], wanted: boolean) =>
set.mutate({ ids: [torrent.id], [wanted ? 'files-wanted' : 'files-unwanted']: indices });
const setPriority = (indices: number[], priority: number) => {
const field =
priority === PRIORITY.High ? 'priority-high' : priority === PRIORITY.Low ? 'priority-low' : 'priority-normal';
set.mutate({ ids: [torrent.id], [field]: indices });
};
const toggleCollapsed = (key: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const renderNode = (node: FileNode, depth: number): React.ReactNode[] => {
const isDir = node.index == null;
const isCollapsed = collapsed.has(node.key);
const nodeStats = node.indices.map((i) => stats[i]).filter(Boolean);
const allWanted = nodeStats.length > 0 && nodeStats.every((s) => s!.wanted);
const someWanted = nodeStats.some((s) => s!.wanted);
const priorities = new Set(nodeStats.map((s) => s!.priority));
const priority = priorities.size === 1 ? [...priorities][0]! : null;
const fraction = node.size > 0 ? node.completed / node.size : 0;
const row = (
<div
key={node.key}
className="grid grid-cols-[minmax(0,1fr)_90px_120px_90px_28px] items-center gap-2 border-b border-border/40 px-3 py-1.5 text-xs hover:bg-muted/40"
>
<div className="flex min-w-0 items-center gap-1.5" style={{ paddingLeft: depth * 14 }}>
{isDir ? (
<button type="button" onClick={() => toggleCollapsed(node.key)} className="shrink-0">
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isCollapsed ? '' : 'rotate-90'}`} />
</button>
) : (
<span className="w-3.5 shrink-0" />
)}
<Checkbox
checked={allWanted ? true : someWanted ? 'indeterminate' : false}
onCheckedChange={() => setWanted(node.indices, !allWanted)}
className="shrink-0"
/>
{isDir ? (
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<File className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className={`truncate ${someWanted ? '' : 'text-muted-foreground line-through'}`} title={node.key}>
{node.name}
</span>
</div>
<span className="text-right tabular-nums text-muted-foreground">{formatSize(node.size)}</span>
<div className="flex items-center gap-2">
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-info" style={{ width: `${fraction * 100}%` }} />
</div>
<span className="w-10 shrink-0 text-right tabular-nums text-muted-foreground">{formatPercent(fraction)}</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="text-left text-muted-foreground hover:text-foreground">
{priority == null ? 'mixed' : PRIORITY_LABELS[priority]}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setPriority(node.indices, PRIORITY.High)}>High</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setPriority(node.indices, PRIORITY.Normal)}>Normal</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setPriority(node.indices, PRIORITY.Low)}>Low</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
onClick={() => setRenaming(node.key)}
className="text-muted-foreground hover:text-foreground"
aria-label={`Rename ${node.name}`}
>
<Pencil className="h-3 w-3" />
</button>
</div>
);
if (isDir && !isCollapsed) {
return [row, ...node.children.flatMap((child) => renderNode(child, depth + 1))];
}
return [row];
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 border-b border-border px-3 py-2 text-xs text-muted-foreground">
<span>
{wantedCount} of {torrent.files.length} files · {formatSize(wantedBytes)} wanted
</span>
<span className="flex-1" />
<Button
size="sm"
variant="ghost"
onClick={() =>
setWanted(
torrent.files!.map((_, i) => i),
true,
)
}
>
Select all
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost">
Set all priorities
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{[PRIORITY.High, PRIORITY.Normal, PRIORITY.Low].map((p) => (
<DropdownMenuItem
key={p}
onSelect={() =>
setPriority(
torrent.files!.map((_, i) => i),
p,
)
}
>
{PRIORITY_LABELS[p]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="min-h-0 flex-1 overflow-auto">{tree.children.flatMap((child) => renderNode(child, 0))}</div>
<RenameDialog
open={renaming != null}
onOpenChange={(open) => !open && setRenaming(null)}
torrent={torrent}
path={renaming ?? ''}
/>
</div>
);
};
+128
View File
@@ -0,0 +1,128 @@
import type { ReactNode } from 'react';
import type { Torrent } from '../shared';
import { Check, Copy } from 'lucide-react';
import { useState } from 'react';
import {
formatDate,
formatDuration,
formatEta,
formatPercent,
formatRatio,
formatSize,
formatSpeed,
PRIORITY_LABELS,
statusLabel,
} from '../format';
import { copyToClipboard } from 'helpers/clipboard';
// The "what is this torrent" tab: transfer numbers, then the immutable facts from the metainfo.
type GeneralTabProps = { torrent: Torrent };
export const GeneralTab = ({ torrent: t }: GeneralTabProps) => (
<div className="space-y-5 p-4">
{t.error !== 0 && t.errorString && (
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">{t.errorString}</p>
)}
<Section title="Transfer">
<Field label="Status" value={statusLabel(t)} />
<Field label="Progress" value={formatPercent(t.percentDone)} />
<Field label="Downloaded" value={formatSize(t.downloadedEver)} />
<Field label="Uploaded" value={formatSize(t.uploadedEver)} />
<Field label="Ratio" value={formatRatio(t.uploadRatio)} />
<Field label="Remaining" value={formatSize(t.leftUntilDone)} />
<Field label="Down speed" value={formatSpeed(t.rateDownload) || '—'} />
<Field label="Up speed" value={formatSpeed(t.rateUpload) || '—'} />
<Field label="ETA" value={t.percentDone >= 1 ? '—' : formatEta(t.eta)} />
<Field label="Seeds / peers" value={`${t.peersSendingToUs} / ${t.peersGettingFromUs}`} />
<Field label="Seeding time" value={formatDuration(t.secondsSeeding)} />
{/* Corrupt bytes are re-downloaded silently, so this is the only place a failing disk or a bad peer
shows up before the ratio quietly goes wrong. */}
<Field label="Corrupt" value={t.corruptEver ? formatSize(t.corruptEver) : '—'} />
</Section>
<Section title="Dates">
<Field label="Added" value={formatDate(t.addedDate)} />
<Field label="Completed" value={formatDate(t.doneDate)} />
<Field label="Last active" value={formatDate(t.activityDate)} />
<Field label="Created" value={formatDate(t.dateCreated)} />
</Section>
<Section title="Torrent">
<Field label="Total size" value={formatSize(t.totalSize)} />
<Field label="Files" value={String(t['file-count'] ?? '—')} />
<Field label="Pieces" value={`${t.pieceCount} × ${formatSize(t.pieceSize)}`} />
<Field label="Privacy" value={t.isPrivate ? 'Private torrent' : 'Public torrent'} />
<Field label="Priority" value={PRIORITY_LABELS[t.bandwidthPriority] ?? '—'} />
<Field label="Queue position" value={String(t.queuePosition)} />
<Field label="Creator" value={t.creator || '—'} />
<Field label="Peer limit" value={String(t['peer-limit'] ?? '—')} />
</Section>
<Section title="Location" columns={1}>
<Field label="Download directory" value={t.downloadDir} mono />
<Field label="Hash" value={t.hashString ?? '—'} mono copy={t.hashString} />
<Field label="Magnet" value={t.magnetLink || '—'} mono truncate copy={t.magnetLink} />
{t.labels.length > 0 && <Field label="Labels" value={t.labels.join(', ')} />}
{t.comment && <Field label="Comment" value={t.comment} />}
</Section>
{t.peersFrom && (
<Section title="Peer sources">
<Field label="Tracker" value={String(t.peersFrom.fromTracker)} />
<Field label="DHT" value={String(t.peersFrom.fromDht)} />
<Field label="PEX" value={String(t.peersFrom.fromPex)} />
<Field label="Incoming" value={String(t.peersFrom.fromIncoming)} />
<Field label="Local discovery" value={String(t.peersFrom.fromLpd)} />
<Field label="Cache" value={String(t.peersFrom.fromCache)} />
</Section>
)}
</div>
);
const Section = ({ title, children, columns = 2 }: { title: string; children: ReactNode; columns?: number }) => (
<section>
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
<dl className={`grid gap-x-6 gap-y-1.5 ${columns === 1 ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}`}>
{children}
</dl>
</section>
);
type FieldProps = { label: string; value: string; mono?: boolean; truncate?: boolean; copy?: string };
const Field = ({ label, value, mono, truncate, copy }: FieldProps) => (
<div className="flex min-w-0 items-baseline gap-3 text-xs">
<dt className="w-32 shrink-0 text-muted-foreground">{label}</dt>
<dd className={`min-w-0 flex-1 ${mono ? 'font-mono' : ''} ${truncate ? 'truncate' : 'break-words'}`} title={value}>
{value}
</dd>
{copy && <CopyButton value={copy} />}
</div>
);
const CopyButton = ({ value }: { value: string }) => {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => {
// copyToClipboard handles the insecure-origin case — navigator.clipboard does not exist over
// plain http, which Officer IS reached over on a tailnet. This comment used to say "Officer is
// always behind HTTPS"; it was not, and the button silently copied nothing.
void copyToClipboard(value).then(
() => {
setCopied(true);
setTimeout(() => setCopied(false), 1200);
},
() => undefined,
);
}}
aria-label="Copy"
>
{copied ? <Check className="h-3 w-3 text-success" /> : <Copy className="h-3 w-3" />}
</button>
);
};
+57
View File
@@ -0,0 +1,57 @@
import type { Torrent } from '../shared';
import { Lock } from 'lucide-react';
import { describePeerFlags, formatPercent, formatSpeed } from '../format';
// Connected peers. Not sortable and not virtualised on purpose: the list is capped by the torrent's peer
// limit (50 by default), it churns every poll, and a sort you have to re-establish every five seconds is
// worse than no sort.
type PeersTabProps = { torrent: Torrent };
const HEAD = 'px-3 py-1.5 text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground';
export const PeersTab = ({ torrent }: PeersTabProps) => {
const peers = torrent.peers ?? [];
if (!peers.length) {
return <p className="p-6 text-center text-sm text-muted-foreground">No peers connected.</p>;
}
return (
<div className="h-full overflow-auto">
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 bg-background/95 backdrop-blur">
<tr className="border-b border-border">
<th className={HEAD}>Address</th>
<th className={HEAD}>Client</th>
<th className={`${HEAD} text-right`}>Progress</th>
<th className={`${HEAD} text-right`}>Down</th>
<th className={`${HEAD} text-right`}>Up</th>
<th className={HEAD}>Flags</th>
</tr>
</thead>
<tbody>
{peers.map((peer) => (
<tr key={`${peer.address}:${peer.port}`} className="border-b border-border/40 hover:bg-muted/40">
<td className="px-3 py-1.5 font-mono">
<span className="flex items-center gap-1.5">
{peer.isEncrypted && <Lock className="h-3 w-3 shrink-0 text-success" aria-label="Encrypted" />}
{peer.address}:{peer.port}
</span>
</td>
<td className="max-w-[200px] truncate px-3 py-1.5" title={peer.clientName}>
{peer.clientName || '—'}
</td>
<td className="px-3 py-1.5 text-right tabular-nums">{formatPercent(peer.progress)}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-info">{formatSpeed(peer.rateToClient)}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-success">{formatSpeed(peer.rateToPeer)}</td>
<td className="px-3 py-1.5 font-mono text-muted-foreground" title={describePeerFlags(peer.flagStr)}>
{peer.flagStr}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
+107
View File
@@ -0,0 +1,107 @@
import type { Torrent } from '../shared';
import { CircleAlert, CircleCheck, Pencil, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { formatDate } from '../format';
import { useTorrentMutations } from '../useTransmissionData';
// One card per tracker, grouped by tier. The point of this tab is diagnosing a torrent that isn't finding
// peers, so the failure text gets as much room as the success numbers.
type TrackersTabProps = { torrent: Torrent; onEdit: () => void };
export const TrackersTab = ({ torrent, onEdit }: TrackersTabProps) => {
const { act } = useTorrentMutations();
const trackers = torrent.trackerStats ?? [];
const tiers = new Map<number, typeof trackers>();
for (const tracker of trackers) {
tiers.set(tracker.tier, [...(tiers.get(tracker.tier) ?? []), tracker]);
}
return (
<div className="h-full overflow-auto">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<span className="flex-1 text-xs text-muted-foreground">
{trackers.length} tracker{trackers.length === 1 ? '' : 's'} in {tiers.size} tier
{tiers.size === 1 ? '' : 's'}
</span>
<Button size="sm" variant="ghost" onClick={() => act.mutate({ ids: [torrent.id], action: 'reannounce' })}>
<RefreshCw className="mr-1.5 h-3 w-3" />
Reannounce
</Button>
<Button size="sm" variant="ghost" onClick={onEdit}>
<Pencil className="mr-1.5 h-3 w-3" />
Edit
</Button>
</div>
{trackers.length === 0 && (
<p className="p-6 text-center text-sm text-muted-foreground">
No trackers. This torrent relies on DHT and peer exchange.
</p>
)}
<div className="space-y-4 p-3">
{[...tiers.entries()]
.sort((a, b) => a[0] - b[0])
.map(([tier, list]) => (
<section key={tier}>
<h3 className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Tier {tier + 1}
</h3>
<div className="space-y-2">
{list.map((tracker) => (
<div key={tracker.id} className="rounded-lg border border-border p-3 text-xs">
<div className="flex items-start gap-2">
{tracker.lastAnnounceSucceeded ? (
<CircleCheck className="mt-0.5 h-3.5 w-3.5 shrink-0 text-success" />
) : (
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
)}
<div className="min-w-0 flex-1">
<div className="break-all font-mono">{tracker.announce}</div>
{tracker.isBackup && (
<div className="mt-0.5 text-muted-foreground">
Backup — only tried if the tier above fails entirely.
</div>
)}
</div>
<div className="shrink-0 text-right tabular-nums text-muted-foreground">
{tracker.seederCount >= 0 ? `${tracker.seederCount} seeds` : 'seeds unknown'}
<br />
{tracker.leecherCount >= 0 ? `${tracker.leecherCount} peers` : 'peers unknown'}
</div>
</div>
<dl className="mt-2 grid grid-cols-1 gap-x-6 gap-y-1 sm:grid-cols-2">
<Row label="Last announce" value={formatDate(tracker.lastAnnounceTime)} />
<Row
label="Result"
value={tracker.lastAnnounceResult || '—'}
tone={tracker.lastAnnounceSucceeded ? undefined : 'text-warning'}
/>
<Row label="Next announce" value={formatDate(tracker.nextAnnounceTime)} />
<Row label="Peers returned" value={String(tracker.lastAnnouncePeerCount)} />
<Row label="Last scrape" value={formatDate(tracker.lastScrapeTime)} />
<Row
label="Scrape result"
value={tracker.lastScrapeResult || '—'}
tone={tracker.lastScrapeSucceeded ? undefined : 'text-warning'}
/>
</dl>
</div>
))}
</div>
</section>
))}
</div>
</div>
);
};
const Row = ({ label, value, tone }: { label: string; value: string; tone?: string }) => (
<div className="flex min-w-0 items-baseline gap-2">
<dt className="w-28 shrink-0 text-muted-foreground">{label}</dt>
<dd className={`min-w-0 flex-1 break-words ${tone ?? ''}`}>{value}</dd>
</div>
);