add a transmission sidecar and ui
officer-transmission is a new pm2 peer that owns the transmission rpc
connection and exposes a curated /_officer/* contract instead of proxying
raw rpc. it absorbs the three quirks callers otherwise have to know about:
the 409 x-transmission-session-id handshake, failures returned as
{"result": "..."} inside http 200, and basic auth where an empty username
must send no header at all.
/transmission is the ui, on the workspace/panel framework: a filter nav and
three sections (torrents, stats, settings). the torrent list is virtualised
with 30 available columns, multi-select, and a right-click menu; the detail
pane covers general, files as a real tree, peers and trackers. filters and
the open torrent live in the url, so a filtered view is a link.
phase 1 goal was parity with _references/transmission-web. follow-up work is
recorded in TODO.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,36 @@ Deferred work. Context: Officer is collapsing from multi-tenant / open-source-re
|
||||
as one of the tabs (main view).
|
||||
- Open question: tabs vs. some other view switcher; which view is default.
|
||||
|
||||
## Transmission
|
||||
|
||||
Phase 1 (parity with `_references/transmission-web`) shipped 2026-07-30: the `officer-transmission`
|
||||
sidecar plus `/transmission` (torrents / stats / settings). Everything below is **phase 2** — none of
|
||||
it exists in the reference app, so none of it was in scope for parity.
|
||||
|
||||
### Probably needed regardless
|
||||
|
||||
- [ ] **Resizable detail pane.** `TorrentsView` pins it at `DETAIL_HEIGHT = '45%'`, which is a guess.
|
||||
Either a drag handle or a persisted height in `useLocalStorageState`, alongside the column set.
|
||||
- [ ] **Revisit `DEFAULT_VISIBLE_COLUMNS`.** Eleven of thirty, chosen before ever seeing the table
|
||||
rendered against the real 43-torrent library. Likely wrong in both directions.
|
||||
- [ ] **Files tab on huge torrents.** `detail/FilesTab.tsx` builds the whole tree and renders every
|
||||
node — no virtualisation. Fine at 14 files; unknown at a few thousand. If it stalls, the fix is
|
||||
`useVirtualizer` over a flattened visible-node list, the same shape as `TorrentTable`.
|
||||
|
||||
### Ideas, unprioritised
|
||||
|
||||
- [ ] **Container→host path mapping.** The daemon reports its own namespace (`/downloads/complete`),
|
||||
which is not a path on this host. A mapping table (`/downloads` → `~/hdds/08_TB_01/Torrents`,
|
||||
`/downloadsTV` → `14_TB_02/Torrents`) would make locations clickable through to `/files` and
|
||||
make "Set location" offer real destinations. Deliberately dropped from phase 1: the reference
|
||||
app has no such feature, so it would have been config nothing read.
|
||||
- [ ] **Push instead of poll.** Transmission RPC has no push channel, so `useTransmissionData` polls
|
||||
every 5s. The sidecar could poll once and fan out over a WebSocket, which is both cheaper and
|
||||
what every other live surface in the platform already does.
|
||||
- [ ] **Cross-surface links.** Soulseek and `download-media` both land files in the same library;
|
||||
Transmission is the third door to it. Worth a think about whether they should know about each
|
||||
other at all.
|
||||
|
||||
## Known bugs
|
||||
|
||||
- [ ] **`bootstrap.ts` runs `npm install -g` for Pi on every boot.** `findPiPackageDir` checks stale
|
||||
|
||||
@@ -76,5 +76,11 @@ module.exports = {
|
||||
args: 'run src/servers/sidecar/headscale/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-transmission',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/transmission/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -44,6 +44,8 @@ export function App() {
|
||||
<Route path="/soulseek" element={<Dashboard.SoulseekScreen />} />
|
||||
<Route path="/headscale" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/headscale/:section" element={<Dashboard.HeadscaleScreen />} />
|
||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
||||
<Route path="/transmission/:section" element={<Dashboard.TransmissionScreen />} />
|
||||
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
|
||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ import {
|
||||
Activity,
|
||||
Radio,
|
||||
Network,
|
||||
ArrowDownUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
@@ -145,6 +146,7 @@ export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Music', to: '/music', icon: Music, color: '#22c55e' },
|
||||
{ label: 'Soulseek', to: '/soulseek', image: '/slskd.png', color: '#ffffff' },
|
||||
{ label: 'Headscale', to: '/headscale', icon: Network, color: '#818cf8' },
|
||||
{ label: 'Transmission', to: '/transmission', icon: ArrowDownUp, color: '#e11d48' },
|
||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||
{ label: 'Jobs', to: '/jobs', icon: Workflow, color: '#14b8a6' },
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Navigate, useParams } from 'react-router';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import {
|
||||
WorkspaceView,
|
||||
DEFAULT_TRANSMISSION_SECTION,
|
||||
transmissionSectionPath,
|
||||
isTransmissionSection,
|
||||
} from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /transmission uses the Workspace/Panel system (like /headscale): a filter nav on the left and the section
|
||||
// view on the right. Both talk to the officer-transmission sidecar through the /api/transmission auth proxy,
|
||||
// which holds no Transmission credentials of its own — the RPC URL and any Basic auth live in the sidecar.
|
||||
//
|
||||
// The open section is :section in the URL and the torrent filters are query params, so both panels read the
|
||||
// URL with useParams/useSearchParams instead of passing state between themselves over a channel. This screen
|
||||
// backs both /transmission and /transmission/:section and is the single place that decides what an absent or
|
||||
// bogus section means.
|
||||
|
||||
const ALLOWED_APP_TYPES = new Set<string | null>(['transmission-nav', 'transmission-view', null]);
|
||||
|
||||
function normalizeLayout(node: LayoutNode): LayoutNode {
|
||||
if (node.type === 'panel') {
|
||||
return ALLOWED_APP_TYPES.has(node.appType) ? node : { ...node, appType: 'transmission-view' };
|
||||
}
|
||||
const children = node.children.map((c) => {
|
||||
const fixed = normalizeLayout(c.node);
|
||||
return fixed === c.node ? c : { ...c, node: fixed };
|
||||
});
|
||||
const changed = children.some((c, i) => c !== node.children[i]);
|
||||
return changed ? { ...node, children } : node;
|
||||
}
|
||||
|
||||
export const TransmissionScreen = () => {
|
||||
const { section } = useParams();
|
||||
const rawWorkspace = useDashboardState<LayoutNode>('screens/transmission', defaultLayout);
|
||||
|
||||
const workspace = useMemo(() => {
|
||||
const fixed = normalizeLayout(rawWorkspace.value);
|
||||
if (fixed === rawWorkspace.value) return rawWorkspace;
|
||||
return { ...rawWorkspace, value: fixed };
|
||||
}, [rawWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
||||
rawWorkspace.setValue(workspace.value);
|
||||
}
|
||||
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
||||
|
||||
// Bare /transmission, or a section that doesn't exist, resolves to a canonical URL rather than rendering a
|
||||
// default while the address bar says something else — the nav highlight is derived from the URL.
|
||||
if (!isTransmissionSection(section)) {
|
||||
return <Navigate to={transmissionSectionPath(DEFAULT_TRANSMISSION_SECTION)} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView workspace={workspace} locked />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'transmission-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'transmission-nav', appType: 'transmission-nav' }, size: 22 },
|
||||
{ node: { type: 'panel', id: 'transmission-view', appType: 'transmission-view' }, size: 78 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './TransmissionScreen';
|
||||
@@ -13,6 +13,7 @@ export * from './Files';
|
||||
export * from './Music';
|
||||
export * from './Soulseek';
|
||||
export * from './Headscale';
|
||||
export * from './Transmission';
|
||||
export * from './SystemMonitor';
|
||||
export * from './Activity';
|
||||
export * from './CodeEditor';
|
||||
|
||||
@@ -18,6 +18,7 @@ const RULES: TitleRule[] = [
|
||||
{ match: (p) => p.startsWith('/music'), title: 'Music' },
|
||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||
{ match: (p) => p.startsWith('/headscale'), title: 'Headscale' },
|
||||
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
|
||||
{ match: (p) => p.startsWith('/system-monitor'), title: 'System Monitor' },
|
||||
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
|
||||
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getTransmissionServerUrl } from './sidecar-server';
|
||||
|
||||
// Thin reverse-proxy for /api/transmission/*. The platform's ONLY job here is AUTH + FORWARDING: this router
|
||||
// mounts under the protected /api tree (userMiddleware upstream authenticates the owner), then forwards the
|
||||
// subpath + query + body to the officer-transmission sidecar, which OWNS the Transmission contract and holds
|
||||
// the daemon credentials.
|
||||
//
|
||||
// A catch-all with no routes of its own. The sidecar exposes only Officer-owned routes under /_officer/,
|
||||
// because Transmission's RPC is a single POST endpoint guarded by a rotating CSRF token — proxying it raw
|
||||
// would push the handshake into the browser. The full contract is documented at the top of
|
||||
// src/servers/sidecar/transmission/index.ts. It is opaque from here: this file must never grow Transmission
|
||||
// logic.
|
||||
|
||||
export const transmissionRouter = createRouter();
|
||||
|
||||
const PREFIX = '/api/transmission';
|
||||
|
||||
transmissionRouter.all('/*', async (ctx) => {
|
||||
const baseUrl = getTransmissionServerUrl();
|
||||
if (!baseUrl) return ctx.text('transmission sidecar not available', 503);
|
||||
|
||||
const url = new URL(ctx.req.url);
|
||||
const subpath = url.pathname.slice(PREFIX.length) || '/';
|
||||
const target = `${baseUrl}${subpath}${url.search}`;
|
||||
|
||||
const method = ctx.req.method;
|
||||
const headers: Record<string, string> = {};
|
||||
const contentType = ctx.req.header('content-type');
|
||||
if (contentType) headers['Content-Type'] = contentType;
|
||||
// Forward the authenticated user id so the sidecar can serve its Officer-owned routes. The sidecar binds
|
||||
// loopback only, so this header is trusted.
|
||||
headers['X-Officer-User'] = String(ctx.get('user').id);
|
||||
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, {
|
||||
method,
|
||||
headers,
|
||||
body: hasBody ? await ctx.req.arrayBuffer() : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[transmission] proxy fetch failed', { target, error: String(err) });
|
||||
return ctx.text('transmission sidecar unreachable', 502);
|
||||
}
|
||||
|
||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
|
||||
// The officer-transmission sidecar starts its HTTP server on a random loopback port and reports it here on
|
||||
// connect. We remember it so `/api/transmission/*` always forwards to the current sidecar. The platform
|
||||
// holds NO knowledge of Transmission itself — not its URL, and not its credentials.
|
||||
|
||||
let serverPort: number | null = null;
|
||||
|
||||
sidecar.on('transmission:server', (msg) => {
|
||||
const port = (msg as { port?: number }).port;
|
||||
if (typeof port !== 'number') return;
|
||||
serverPort = port;
|
||||
console.log(`[transmission] sidecar registered on port ${port}`);
|
||||
});
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if the sidecar hasn't reported in yet. */
|
||||
export function getTransmissionServerUrl(): string | null {
|
||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { musicRouter } from './api/music/router';
|
||||
import { vaultRouter } from './api/vault/router';
|
||||
import { slskdRouter } from './api/slskd/router';
|
||||
import { headscaleRouter } from './api/headscale/router';
|
||||
import { transmissionRouter } from './api/transmission/router';
|
||||
import { vpnRouter } from './api/vpn/router';
|
||||
import { systemMonitorRouter } from './api/system-monitor/system-monitor';
|
||||
import { activityRouter } from './api/activity/router';
|
||||
@@ -30,6 +31,7 @@ import './api/music/sidecar-server'; // side-effect: capture the officer-music a
|
||||
import './api/vault/sidecar-server'; // side-effect: capture the officer-vault reverse-proxy port
|
||||
import './api/slskd/sidecar-server'; // side-effect: capture the officer-slskd reverse-proxy port
|
||||
import './api/headscale/sidecar-server'; // side-effect: capture the officer-headscale server port
|
||||
import './api/transmission/sidecar-server'; // side-effect: capture the officer-transmission server port
|
||||
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
||||
import { dockRouter } from './api/dock/dock';
|
||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||
@@ -113,6 +115,7 @@ protectedRouter.route('/file-browser', fileBrowserRouter);
|
||||
protectedRouter.route('/music', musicRouter);
|
||||
protectedRouter.route('/slskd', slskdRouter);
|
||||
protectedRouter.route('/headscale', headscaleRouter);
|
||||
protectedRouter.route('/transmission', transmissionRouter);
|
||||
protectedRouter.route('/vpn', vpnRouter);
|
||||
protectedRouter.route('/system-monitor', systemMonitorRouter);
|
||||
protectedRouter.route('/activity', activityRouter);
|
||||
|
||||
@@ -70,6 +70,8 @@ export type SidecarEvent =
|
||||
| { type: 'slskd:server'; port: number }
|
||||
// Headscale — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||
| { type: 'headscale:server'; port: number }
|
||||
// Transmission — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||
| { type: 'transmission:server'; port: number }
|
||||
// Generic
|
||||
| { type: 'error'; id?: string; error: string };
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { probe } from './rpc';
|
||||
import { getTransmissionBase } from './upstream';
|
||||
|
||||
// The officer-transmission sidecar. Owns the whole Transmission contract for Officer: the daemon URL and
|
||||
// credentials, the X-Transmission-Session-Id CSRF handshake, and the translation from Transmission's
|
||||
// single-endpoint RPC vocabulary into real REST paths. The platform API is a thin auth-gated forwarder
|
||||
// (src/servers/api/transmission/router.ts) holding no Transmission credentials.
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP CONTRACT — the platform strips its /api/transmission mount prefix before forwarding.
|
||||
//
|
||||
// GET /_health ours. Probes the daemon with a cheap session-get.
|
||||
// GET /_officer/session full session settings + version + rpc-version
|
||||
// POST /_officer/session write a partial settings object (whitelisted), returns it read back
|
||||
// GET /_officer/stats session-stats: cumulative + current-session counters
|
||||
// GET /_officer/torrents the list poll — LIST_FIELDS for every torrent, decorated
|
||||
// GET /_officer/torrents/:id one torrent with files/peers/trackers (DETAIL_FIELDS)
|
||||
// POST /_officer/torrents/add {metainfo|filename, downloadDir?, labels?, paused?, …}
|
||||
// → {status:'added'|'duplicate', torrent}
|
||||
// POST /_officer/torrents/action {ids, action} — start|start-now|stop|verify|reannounce|queue-*
|
||||
// POST /_officer/torrents/set {ids, …whitelisted torrent-set fields}
|
||||
// POST /_officer/torrents/location {ids, location, move}
|
||||
// POST /_officer/torrents/rename {id, path, name}
|
||||
// POST /_officer/torrents/remove {ids, deleteLocalData}
|
||||
// GET /_officer/free-space?path= free space at a path
|
||||
// POST /_officer/port-test is the peer port reachable from outside
|
||||
// POST /_officer/blocklist-update refresh the blocklist, returns the new size
|
||||
// anything else 404
|
||||
//
|
||||
// There is deliberately NO transparent /transmission/rpc passthrough. Transmission multiplexes every verb
|
||||
// through one POST body, reports failures as `{"result": "<error>"}` inside a 200, and demands a CSRF token
|
||||
// that rotates on every daemon restart. Proxying that raw would push all three into the browser. Every
|
||||
// quirk is absorbed here — see rpc.ts.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
const p = probeServer.port;
|
||||
probeServer.stop(true);
|
||||
if (p == null) throw new Error('failed to acquire a free port');
|
||||
return p;
|
||||
}
|
||||
|
||||
const port = getFreePort();
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
// A base64 .torrent for a large multi-file release travels in the request body of /torrents/add.
|
||||
maxRequestBodySize: 256 * 1024 * 1024,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === '/_health') {
|
||||
if (!getTransmissionBase()) {
|
||||
return Response.json({ ok: false, error: 'TRANSMISSION_URL not configured' }, { status: 503 });
|
||||
}
|
||||
const started = Date.now();
|
||||
const result = await probe();
|
||||
return Response.json({ ...result, ms: Date.now() - started }, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/_officer/')) {
|
||||
try {
|
||||
const res = await handleOfficerRoute(req, url);
|
||||
if (res) return res;
|
||||
return Response.json({ error: 'not found' }, { status: 404 });
|
||||
} catch (err) {
|
||||
console.error(`[transmission] ${req.method} ${url.pathname} failed`, err);
|
||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: 'not found' }, { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[transmission] listening on 127.0.0.1:${port} -> ${getTransmissionBase() ?? '(TRANSMISSION_URL unset)'}`);
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'transmission',
|
||||
capabilities: ['transmission'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
onConnected() {
|
||||
connection.send({ type: 'transmission:server', port });
|
||||
console.log(`[transmission] reported server port ${port} to API`);
|
||||
},
|
||||
});
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[transmission] ${signal} received, shutting down...`);
|
||||
try {
|
||||
server.stop(true);
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,370 @@
|
||||
import type { SessionSettings, SessionStats, Torrent } from './types';
|
||||
import { DETAIL_FIELDS, LIST_FIELDS } from './types';
|
||||
import { decorate, rpc, TransmissionError } from './rpc';
|
||||
|
||||
// Officer-owned routes for the transmission sidecar — the entire feature surface lives under /_officer/.
|
||||
//
|
||||
// Nothing here is a passthrough. Transmission speaks a single POST endpoint with a `method` field, which is
|
||||
// a terrible fit for an HTTP cache, for auth scoping and for a browser client: it would mean shipping the
|
||||
// 409 handshake, the `result: 'success'` convention and the whole method vocabulary into the SPA. So the
|
||||
// verbs are re-expressed as real REST paths and every quirk is absorbed in rpc.ts.
|
||||
|
||||
export type OfficerContext = { req: Request; url: URL; userId: number };
|
||||
|
||||
/** 400 with a machine-readable reason. */
|
||||
export const badRequest = (error: string) => Response.json({ error }, { status: 400 });
|
||||
/** 404 for an unknown /_officer/ path or a missing object. */
|
||||
export const notFound = (error = 'not found') => Response.json({ error }, { status: 404 });
|
||||
/** 405 when the path exists but the verb doesn't. */
|
||||
export const methodNotAllowed = () => Response.json({ error: 'method not allowed' }, { status: 405 });
|
||||
|
||||
async function readJson(req: Request): Promise<Record<string, unknown> | null> {
|
||||
const body = await req.json().catch(() => null);
|
||||
return body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
/** Torrent ids as Transmission wants them: a number array. Rejects anything that isn't a positive integer. */
|
||||
function readIds(body: Record<string, unknown>): number[] | null {
|
||||
const raw = body['ids'];
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const ids = raw.map(Number).filter((n) => Number.isInteger(n) && n > 0);
|
||||
return ids.length === raw.length ? ids : null;
|
||||
}
|
||||
|
||||
// Actions that take {ids} and nothing else. Mapping them by name keeps the browser from naming RPC methods.
|
||||
const ACTIONS: Record<string, string> = {
|
||||
start: 'torrent-start',
|
||||
'start-now': 'torrent-start-now',
|
||||
stop: 'torrent-stop',
|
||||
verify: 'torrent-verify',
|
||||
reannounce: 'torrent-reannounce',
|
||||
'queue-top': 'queue-move-top',
|
||||
'queue-up': 'queue-move-up',
|
||||
'queue-down': 'queue-move-down',
|
||||
'queue-bottom': 'queue-move-bottom',
|
||||
};
|
||||
|
||||
// Whitelists, not passthroughs. Transmission silently accepts some nonsense and hard-fails on other, so the
|
||||
// browser is never allowed to name an arbitrary RPC argument — a typo here is a 400, not a silent no-op.
|
||||
const TORRENT_SET_FIELDS = new Set([
|
||||
'bandwidthPriority',
|
||||
'downloadLimit',
|
||||
'downloadLimited',
|
||||
'files-unwanted',
|
||||
'files-wanted',
|
||||
'group',
|
||||
'honorsSessionLimits',
|
||||
'labels',
|
||||
'peer-limit',
|
||||
'priority-high',
|
||||
'priority-low',
|
||||
'priority-normal',
|
||||
'queuePosition',
|
||||
'seedIdleLimit',
|
||||
'seedIdleMode',
|
||||
'seedRatioLimit',
|
||||
'seedRatioMode',
|
||||
'sequential_download',
|
||||
'trackerAdd',
|
||||
'trackerList',
|
||||
'uploadLimit',
|
||||
'uploadLimited',
|
||||
]);
|
||||
|
||||
const SESSION_SET_FIELDS = new Set([
|
||||
'alt-speed-down',
|
||||
'alt-speed-enabled',
|
||||
'alt-speed-time-begin',
|
||||
'alt-speed-time-day',
|
||||
'alt-speed-time-enabled',
|
||||
'alt-speed-time-end',
|
||||
'alt-speed-up',
|
||||
'blocklist-enabled',
|
||||
'blocklist-url',
|
||||
'cache-size-mb',
|
||||
'default-trackers',
|
||||
'dht-enabled',
|
||||
'download-dir',
|
||||
'download-queue-enabled',
|
||||
'download-queue-size',
|
||||
'encryption',
|
||||
'idle-seeding-limit',
|
||||
'idle-seeding-limit-enabled',
|
||||
'incomplete-dir',
|
||||
'incomplete-dir-enabled',
|
||||
'lpd-enabled',
|
||||
'peer-limit-global',
|
||||
'peer-limit-per-torrent',
|
||||
'peer-port',
|
||||
'peer-port-random-on-start',
|
||||
'pex-enabled',
|
||||
'port-forwarding-enabled',
|
||||
'queue-stalled-enabled',
|
||||
'queue-stalled-minutes',
|
||||
'rename-partial-files',
|
||||
'script-torrent-done-enabled',
|
||||
'script-torrent-done-filename',
|
||||
'seed-queue-enabled',
|
||||
'seed-queue-size',
|
||||
'seedRatioLimit',
|
||||
'seedRatioLimited',
|
||||
'speed-limit-down',
|
||||
'speed-limit-down-enabled',
|
||||
'speed-limit-up',
|
||||
'speed-limit-up-enabled',
|
||||
'start-added-torrents',
|
||||
'trash-original-torrent-files',
|
||||
'utp-enabled',
|
||||
]);
|
||||
|
||||
function pick(body: Record<string, unknown>, allowed: Set<string>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (allowed.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an /_officer/* request. Returns null when nothing matches, which the caller turns into a 404.
|
||||
*
|
||||
* The platform injects X-Officer-User after authenticating the owner. We bind loopback only, so its presence
|
||||
* is the trust signal — a request without it did not come through the platform.
|
||||
*/
|
||||
export async function handleOfficerRoute(req: Request, url: URL): Promise<Response | null> {
|
||||
const officerUser = req.headers.get('X-Officer-User');
|
||||
if (!officerUser) return Response.json({ error: 'missing X-Officer-User' }, { status: 401 });
|
||||
|
||||
const userId = Number(officerUser);
|
||||
if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User');
|
||||
|
||||
const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const ctx: OfficerContext = { req, url, userId };
|
||||
|
||||
try {
|
||||
switch (segments[0]) {
|
||||
case 'session':
|
||||
return await handleSession(ctx, segments.slice(1));
|
||||
case 'stats':
|
||||
return await handleStats(ctx);
|
||||
case 'torrents':
|
||||
return await handleTorrents(ctx, segments.slice(1));
|
||||
case 'free-space':
|
||||
return await handleFreeSpace(ctx);
|
||||
case 'port-test':
|
||||
return await handlePortTest(ctx);
|
||||
case 'blocklist-update':
|
||||
return await handleBlocklistUpdate(ctx);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof TransmissionError) return Response.json({ error: err.message }, { status: err.status });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSession(ctx: OfficerContext, rest: string[]): Promise<Response | null> {
|
||||
if (rest.length > 0) return null;
|
||||
|
||||
if (ctx.req.method === 'GET') {
|
||||
// No `fields` argument: session-get returns everything, and the settings UI reads nearly all of it.
|
||||
const session = await rpc<SessionSettings>('session-get');
|
||||
return Response.json({ session });
|
||||
}
|
||||
|
||||
if (ctx.req.method === 'POST') {
|
||||
const body = await readJson(ctx.req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
const args = pick(body, SESSION_SET_FIELDS);
|
||||
if (Object.keys(args).length === 0) return badRequest('no writable session fields in body');
|
||||
await rpc('session-set', args);
|
||||
// Read back rather than echoing the request: Transmission clamps and normalises several of these
|
||||
// (alt-speed times, queue sizes), so the request body is not what the daemon ends up holding.
|
||||
const session = await rpc<SessionSettings>('session-get');
|
||||
return Response.json({ session });
|
||||
}
|
||||
|
||||
return methodNotAllowed();
|
||||
}
|
||||
|
||||
async function handleStats(ctx: OfficerContext): Promise<Response> {
|
||||
if (ctx.req.method !== 'GET') return methodNotAllowed();
|
||||
const stats = await rpc<SessionStats>('session-stats');
|
||||
return Response.json({ stats });
|
||||
}
|
||||
|
||||
async function handleFreeSpace(ctx: OfficerContext): Promise<Response> {
|
||||
if (ctx.req.method !== 'GET') return methodNotAllowed();
|
||||
const path = ctx.url.searchParams.get('path');
|
||||
if (!path) return badRequest('path query parameter is required');
|
||||
const result = await rpc<{ path: string; 'size-bytes': number }>('free-space', { path });
|
||||
return Response.json({ path: result.path, bytes: result['size-bytes'] });
|
||||
}
|
||||
|
||||
async function handlePortTest(ctx: OfficerContext): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||
const result = await rpc<{ 'port-is-open': boolean }>('port-test');
|
||||
return Response.json({ open: result['port-is-open'] });
|
||||
}
|
||||
|
||||
async function handleBlocklistUpdate(ctx: OfficerContext): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||
const result = await rpc<{ 'blocklist-size': number }>('blocklist-update');
|
||||
return Response.json({ size: result['blocklist-size'] });
|
||||
}
|
||||
|
||||
async function handleTorrents(ctx: OfficerContext, rest: string[]): Promise<Response | null> {
|
||||
const { req } = ctx;
|
||||
|
||||
// GET /_officer/torrents — the list poll.
|
||||
if (rest.length === 0 && req.method === 'GET') {
|
||||
const result = await rpc<{ torrents: Torrent[] }>('torrent-get', { fields: [...LIST_FIELDS] });
|
||||
return Response.json({ torrents: result.torrents.map(decorate) });
|
||||
}
|
||||
|
||||
if (rest.length === 1) {
|
||||
const [segment] = rest as [string];
|
||||
|
||||
if (req.method === 'POST') {
|
||||
switch (segment) {
|
||||
case 'add':
|
||||
return await addTorrent(req);
|
||||
case 'action':
|
||||
return await runAction(req);
|
||||
case 'set':
|
||||
return await setTorrent(req);
|
||||
case 'location':
|
||||
return await setLocation(req);
|
||||
case 'rename':
|
||||
return await renamePath(req);
|
||||
case 'remove':
|
||||
return await removeTorrents(req);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /_officer/torrents/:id — the detail poll, one torrent, with the expensive arrays.
|
||||
const id = Number(segment);
|
||||
if (!Number.isInteger(id) || id <= 0) return notFound();
|
||||
if (req.method !== 'GET') return methodNotAllowed();
|
||||
|
||||
const result = await rpc<{ torrents: Torrent[] }>('torrent-get', {
|
||||
ids: [id],
|
||||
fields: [...LIST_FIELDS, ...DETAIL_FIELDS],
|
||||
});
|
||||
const torrent = result.torrents[0];
|
||||
if (!torrent) return notFound('torrent not found');
|
||||
return Response.json({ torrent: decorate(torrent) });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function addTorrent(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
|
||||
const args: Record<string, unknown> = {};
|
||||
// Exactly one source. `metainfo` is a base64 .torrent; `filename` is a magnet link or an http(s) URL.
|
||||
if (typeof body['metainfo'] === 'string' && body['metainfo']) args['metainfo'] = body['metainfo'];
|
||||
else if (typeof body['filename'] === 'string' && body['filename']) args['filename'] = body['filename'];
|
||||
else return badRequest('either metainfo or filename is required');
|
||||
|
||||
if (typeof body['downloadDir'] === 'string' && body['downloadDir']) args['download-dir'] = body['downloadDir'];
|
||||
if (Array.isArray(body['labels'])) args['labels'] = body['labels'].filter((l) => typeof l === 'string');
|
||||
if (typeof body['paused'] === 'boolean') args['paused'] = body['paused'];
|
||||
if (typeof body['bandwidthPriority'] === 'number') args['bandwidthPriority'] = body['bandwidthPriority'];
|
||||
if (typeof body['sequentialDownload'] === 'boolean') args['sequential_download'] = body['sequentialDownload'];
|
||||
|
||||
const result = await rpc<{
|
||||
'torrent-added'?: { id: number; name: string; hashString: string };
|
||||
'torrent-duplicate'?: { id: number; name: string; hashString: string };
|
||||
}>('torrent-add', args);
|
||||
|
||||
const added = result['torrent-added'];
|
||||
const duplicate = result['torrent-duplicate'];
|
||||
// A duplicate is not an error — Transmission reports the torrent you already had, and the UI counts it
|
||||
// separately so a batch add can say "3 added, 1 already present" instead of failing opaquely.
|
||||
if (duplicate) return Response.json({ status: 'duplicate', torrent: duplicate });
|
||||
if (added) return Response.json({ status: 'added', torrent: added });
|
||||
return Response.json({ status: 'added', torrent: null });
|
||||
}
|
||||
|
||||
async function runAction(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
|
||||
const action = typeof body['action'] === 'string' ? body['action'] : '';
|
||||
const method = ACTIONS[action];
|
||||
if (!method) return badRequest(`unknown action "${action}"`);
|
||||
|
||||
const ids = readIds(body);
|
||||
if (!ids) return badRequest('ids must be an array of positive integers');
|
||||
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
|
||||
|
||||
await rpc(method, { ids });
|
||||
return Response.json({ ok: true, affected: ids.length });
|
||||
}
|
||||
|
||||
async function setTorrent(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
|
||||
const ids = readIds(body);
|
||||
if (!ids) return badRequest('ids must be an array of positive integers');
|
||||
|
||||
const fields = pick(body, TORRENT_SET_FIELDS);
|
||||
if (Object.keys(fields).length === 0) return badRequest('no writable torrent fields in body');
|
||||
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
|
||||
|
||||
await rpc('torrent-set', { ids, ...fields });
|
||||
return Response.json({ ok: true, affected: ids.length });
|
||||
}
|
||||
|
||||
async function setLocation(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
|
||||
const ids = readIds(body);
|
||||
if (!ids) return badRequest('ids must be an array of positive integers');
|
||||
const location = typeof body['location'] === 'string' ? body['location'].trim() : '';
|
||||
if (!location) return badRequest('location is required');
|
||||
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
|
||||
|
||||
// `move: false` re-points the torrent at data already sitting there; `true` physically moves it. Getting
|
||||
// this backwards either loses the data or copies gigabytes unasked, so it is required, not defaulted.
|
||||
const move = body['move'] === true;
|
||||
await rpc('torrent-set-location', { ids, location, move });
|
||||
return Response.json({ ok: true, affected: ids.length });
|
||||
}
|
||||
|
||||
async function renamePath(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
|
||||
const id = Number(body['id']);
|
||||
if (!Number.isInteger(id) || id <= 0) return badRequest('id must be a positive integer');
|
||||
const path = typeof body['path'] === 'string' ? body['path'] : '';
|
||||
const name = typeof body['name'] === 'string' ? body['name'].trim() : '';
|
||||
if (!path) return badRequest('path is required');
|
||||
if (!name) return badRequest('name is required');
|
||||
|
||||
// torrent-rename-path takes ONE id — an array is accepted but the result is undefined. Enforce it.
|
||||
await rpc('torrent-rename-path', { ids: [id], path, name });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
async function removeTorrents(req: Request): Promise<Response> {
|
||||
const body = await readJson(req);
|
||||
if (!body) return badRequest('expected a JSON object body');
|
||||
|
||||
const ids = readIds(body);
|
||||
if (!ids) return badRequest('ids must be an array of positive integers');
|
||||
if (ids.length === 0) return Response.json({ ok: true, affected: 0 });
|
||||
|
||||
const deleteLocalData = body['deleteLocalData'] === true;
|
||||
await rpc('torrent-remove', { ids, 'delete-local-data': deleteLocalData });
|
||||
return Response.json({ ok: true, affected: ids.length, deletedData: deleteLocalData });
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Torrent, TrackerStat } from './types';
|
||||
import { getAuthHeader, getRpcPath, getTransmissionBase } from './upstream';
|
||||
|
||||
// The Transmission RPC call layer. Every upstream request in this sidecar goes through here, so the
|
||||
// wire-level quirks are handled exactly once:
|
||||
//
|
||||
// • THE 409 HANDSHAKE. Transmission's CSRF defence answers the first request of a session with
|
||||
// `409 Conflict` and an `X-Transmission-Session-Id` header, expecting the client to repeat the request
|
||||
// carrying it. The token also rotates whenever the daemon restarts, so this is not a start-up ritual you
|
||||
// can do once — any call can 409 at any time. Absorbing it here is the single biggest reason this
|
||||
// sidecar exists: a client that handles it naively works until the daemon is restarted and then fails
|
||||
// in a way that looks like an auth bug.
|
||||
// • Errors are NOT signalled by HTTP status. A perfectly successful-looking 200 carries
|
||||
// `{"result": "some error string"}`; only `result === 'success'` means it worked.
|
||||
// • Auth is HTTP Basic, and an EMPTY username must send no header at all — see getAuthHeader.
|
||||
// • `torrent-get` with an unknown field name fails the whole call rather than ignoring the field, so
|
||||
// LIST_FIELDS/DETAIL_FIELDS are curated against the running daemon's rpc-version, not guessed.
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */
|
||||
export class TransmissionError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TransmissionError';
|
||||
}
|
||||
}
|
||||
|
||||
// The CSRF token for the current daemon session. Module-level because it is a property of the connection,
|
||||
// not of any one request, and every caller benefits from a refresh any one of them performs.
|
||||
let sessionId: string | null = null;
|
||||
|
||||
type RpcResponse<T> = { result: string; arguments?: T };
|
||||
|
||||
/**
|
||||
* Issue one RPC call. Retries exactly once on 409 after adopting the new session id; a second 409 means
|
||||
* something other than a stale token (a proxy stripping the header, most likely) and is surfaced.
|
||||
*/
|
||||
export async function rpc<T = unknown>(method: string, args: Record<string, unknown> = {}): Promise<T> {
|
||||
return call<T>(method, args, true);
|
||||
}
|
||||
|
||||
async function call<T>(method: string, args: Record<string, unknown>, mayRetry: boolean): Promise<T> {
|
||||
const base = getTransmissionBase();
|
||||
if (!base) throw new TransmissionError(503, 'TRANSMISSION_URL is not configured');
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (sessionId) headers['X-Transmission-Session-Id'] = sessionId;
|
||||
const auth = getAuthHeader();
|
||||
if (auth) headers['Authorization'] = auth;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${base}${getRpcPath()}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ method, arguments: args }),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error && err.name === 'TimeoutError' ? 'timed out' : 'unreachable';
|
||||
throw new TransmissionError(502, `transmission upstream ${reason}`);
|
||||
}
|
||||
|
||||
if (res.status === 409) {
|
||||
const fresh = res.headers.get('X-Transmission-Session-Id');
|
||||
if (fresh && mayRetry) {
|
||||
sessionId = fresh;
|
||||
return call<T>(method, args, false);
|
||||
}
|
||||
throw new TransmissionError(502, 'transmission rejected the session id handshake');
|
||||
}
|
||||
|
||||
if (res.status === 401) throw new TransmissionError(401, 'transmission rejected the credentials');
|
||||
if (!res.ok) throw new TransmissionError(502, `transmission returned ${res.status}`);
|
||||
|
||||
const body = (await res.json().catch(() => null)) as RpcResponse<T> | null;
|
||||
if (!body) throw new TransmissionError(502, 'transmission returned an unreadable body');
|
||||
// A 200 with a non-'success' result is the normal way Transmission reports a failure.
|
||||
if (body.result !== 'success') throw new TransmissionError(502, body.result || 'transmission reported a failure');
|
||||
|
||||
return (body.arguments ?? {}) as T;
|
||||
}
|
||||
|
||||
/** Reachability + credential probe, used by /_health. */
|
||||
export async function probe(): Promise<{ ok: boolean; version?: string; rpcVersion?: number; error?: string }> {
|
||||
try {
|
||||
const args = await rpc<{ version: string; 'rpc-version': number }>('session-get', {
|
||||
fields: ['version', 'rpc-version'],
|
||||
});
|
||||
return { ok: true, version: args.version, rpcVersion: args['rpc-version'] };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Derived fields ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Subdomain prefixes that carry no identity — `tracker.example.org` and `t.example.org` are the same
|
||||
// operator to a human grouping their torrents, and showing them as two entries makes the filter useless.
|
||||
const IGNORED_TRACKER_PREFIXES = new Set(['t', 'tr', 'tk', 'tracker', 'bt', 'open', 'opentracker', 'pt']);
|
||||
|
||||
const ANNOUNCE_STATE: Record<number, string> = {
|
||||
0: 'Inactive',
|
||||
1: 'Waiting',
|
||||
2: 'Queued',
|
||||
3: 'Announcing',
|
||||
};
|
||||
|
||||
/** `udp://tr.example.org:1337/announce` → `example.org`. Empty string when there is no tracker at all. */
|
||||
export function trackerHost(stats: TrackerStat[]): string {
|
||||
const primary = stats.find((t) => !t.isBackup) ?? stats[0];
|
||||
if (!primary) return '';
|
||||
|
||||
const raw = primary.host || primary.announce;
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(raw.includes('://') ? raw : `http://${raw}`).hostname;
|
||||
} catch {
|
||||
host = raw
|
||||
.replace(/^[a-z]+:\/\//i, '')
|
||||
.split('/')[0]!
|
||||
.split(':')[0]!;
|
||||
}
|
||||
|
||||
const parts = host.split('.');
|
||||
// Only strip a prefix when something recognisable remains — `t.co` must not become `co`.
|
||||
if (parts.length > 2 && IGNORED_TRACKER_PREFIXES.has(parts[0]!.toLowerCase())) parts.shift();
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
function trackerStatus(stats: TrackerStat[]): string {
|
||||
const primary = stats.find((t) => !t.isBackup) ?? stats[0];
|
||||
if (!primary) return '';
|
||||
if (primary.lastAnnounceSucceeded === false && primary.lastAnnounceResult) return primary.lastAnnounceResult;
|
||||
return ANNOUNCE_STATE[primary.announceState] ?? '';
|
||||
}
|
||||
|
||||
/** Attach the `officer*` fields the UI groups and filters by. Mutating the parsed body is fine — it's ours. */
|
||||
export function decorate(torrent: Torrent): Torrent {
|
||||
const stats = torrent.trackerStats ?? [];
|
||||
torrent.officerTrackerHost = trackerHost(stats);
|
||||
torrent.officerTrackerStatus = trackerStatus(stats);
|
||||
torrent.officerTrackerErrors = stats.filter((t) => t.lastAnnounceSucceeded === false && t.hasAnnounced).length;
|
||||
return torrent;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Wire types for the Transmission RPC surface this sidecar uses.
|
||||
//
|
||||
// Field names are Transmission's OWN, verbatim — including its inconsistent casing (`percentDone` next to
|
||||
// `begin_piece`, `peer-limit`, `sequential_download`). That is deliberate: renaming them would mean every
|
||||
// future field needs a translation entry, and a name that doesn't appear in Transmission's rpc-spec.md is a
|
||||
// name you cannot grep for when something misbehaves. The only fields we invent are the `officer*` derived
|
||||
// ones at the bottom of Torrent, which are prefixed so they can never be mistaken for upstream data.
|
||||
|
||||
/** Transmission's torrent status enum (rpc-spec.md §3.3). */
|
||||
export const TorrentStatus = {
|
||||
Stopped: 0,
|
||||
CheckWait: 1,
|
||||
Check: 2,
|
||||
DownloadWait: 3,
|
||||
Download: 4,
|
||||
SeedWait: 5,
|
||||
Seed: 6,
|
||||
} as const;
|
||||
|
||||
export type TrackerStat = {
|
||||
announce: string;
|
||||
announceState: number;
|
||||
downloadCount: number;
|
||||
hasAnnounced: boolean;
|
||||
hasScraped: boolean;
|
||||
host: string;
|
||||
id: number;
|
||||
isBackup: boolean;
|
||||
lastAnnouncePeerCount: number;
|
||||
lastAnnounceResult: string;
|
||||
lastAnnounceSucceeded: boolean;
|
||||
lastAnnounceTime: number;
|
||||
lastScrapeResult: string;
|
||||
lastScrapeSucceeded: boolean;
|
||||
lastScrapeTime: number;
|
||||
leecherCount: number;
|
||||
nextAnnounceTime: number;
|
||||
scrape: string;
|
||||
seederCount: number;
|
||||
tier: number;
|
||||
};
|
||||
|
||||
export type TorrentFile = {
|
||||
bytesCompleted: number;
|
||||
length: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type TorrentFileStat = {
|
||||
bytesCompleted: number;
|
||||
priority: number;
|
||||
wanted: boolean;
|
||||
};
|
||||
|
||||
export type TorrentPeer = {
|
||||
address: string;
|
||||
clientName: string;
|
||||
clientIsChoked: boolean;
|
||||
clientIsInterested: boolean;
|
||||
flagStr: string;
|
||||
isDownloadingFrom: boolean;
|
||||
isEncrypted: boolean;
|
||||
isIncoming: boolean;
|
||||
isUploadingTo: boolean;
|
||||
isUTP: boolean;
|
||||
peerIsChoked: boolean;
|
||||
peerIsInterested: boolean;
|
||||
port: number;
|
||||
progress: number;
|
||||
rateToClient: number;
|
||||
rateToPeer: number;
|
||||
};
|
||||
|
||||
export type PeersFrom = {
|
||||
fromCache: number;
|
||||
fromDht: number;
|
||||
fromIncoming: number;
|
||||
fromLpd: number;
|
||||
fromLtep: number;
|
||||
fromPex: number;
|
||||
fromTracker: number;
|
||||
};
|
||||
|
||||
export type Torrent = {
|
||||
id: number;
|
||||
name: string;
|
||||
status: number;
|
||||
totalSize: number;
|
||||
sizeWhenDone: number;
|
||||
leftUntilDone: number;
|
||||
haveValid: number;
|
||||
percentDone: number;
|
||||
metadataPercentComplete: number;
|
||||
recheckProgress?: number;
|
||||
downloadedEver: number;
|
||||
uploadedEver: number;
|
||||
corruptEver?: number;
|
||||
uploadRatio: number;
|
||||
rateDownload: number;
|
||||
rateUpload: number;
|
||||
eta: number;
|
||||
peersSendingToUs: number;
|
||||
peersGettingFromUs: number;
|
||||
addedDate: number;
|
||||
doneDate: number;
|
||||
activityDate: number;
|
||||
secondsSeeding: number;
|
||||
downloadDir: string;
|
||||
bandwidthPriority: number;
|
||||
queuePosition: number;
|
||||
isPrivate: boolean;
|
||||
labels: string[];
|
||||
error: number;
|
||||
errorString: string;
|
||||
pieceCount: number;
|
||||
pieceSize: number;
|
||||
magnetLink: string;
|
||||
group: string;
|
||||
'file-count': number;
|
||||
trackerStats: TrackerStat[];
|
||||
trackerList?: string;
|
||||
// Per-torrent limits — the fields the "Other settings" dialog reads and writes.
|
||||
honorsSessionLimits: boolean;
|
||||
downloadLimited: boolean;
|
||||
downloadLimit: number;
|
||||
uploadLimited: boolean;
|
||||
uploadLimit: number;
|
||||
'peer-limit': number;
|
||||
seedRatioMode: number;
|
||||
seedRatioLimit: number;
|
||||
seedIdleMode: number;
|
||||
seedIdleLimit: number;
|
||||
sequential_download?: boolean;
|
||||
// Detail-only fields — present when fetched through /_officer/torrents/:id.
|
||||
hashString?: string;
|
||||
comment?: string;
|
||||
creator?: string;
|
||||
dateCreated?: number;
|
||||
maxConnectedPeers?: number;
|
||||
files?: TorrentFile[];
|
||||
fileStats?: TorrentFileStat[];
|
||||
peers?: TorrentPeer[];
|
||||
peersFrom?: PeersFrom;
|
||||
// ── Derived by the sidecar, never sent by Transmission ──
|
||||
/**
|
||||
* The primary tracker's host with its port and any ignorable subdomain prefix stripped
|
||||
* (`udp://tr.example.org:1337/announce` → `example.org`). The UI groups by this, so the normalisation
|
||||
* has to be one implementation, not one per caller.
|
||||
*/
|
||||
officerTrackerHost: string;
|
||||
/** Human-readable announce state of the primary tracker, for the "Tracker Status" column. */
|
||||
officerTrackerStatus: string;
|
||||
/** Count of trackers that have reported an error, so the UI can flag a torrent without walking the array. */
|
||||
officerTrackerErrors: number;
|
||||
};
|
||||
|
||||
export type SessionSettings = Record<string, unknown> & {
|
||||
version: string;
|
||||
'rpc-version': number;
|
||||
'download-dir': string;
|
||||
};
|
||||
|
||||
export type SessionStats = {
|
||||
activeTorrentCount: number;
|
||||
pausedTorrentCount: number;
|
||||
torrentCount: number;
|
||||
downloadSpeed: number;
|
||||
uploadSpeed: number;
|
||||
'cumulative-stats': StatsBucket;
|
||||
'current-stats': StatsBucket;
|
||||
};
|
||||
|
||||
export type StatsBucket = {
|
||||
downloadedBytes: number;
|
||||
uploadedBytes: number;
|
||||
filesAdded: number;
|
||||
sessionCount: number;
|
||||
secondsActive: number;
|
||||
};
|
||||
|
||||
/** The fields the torrent list needs. Mirrors what the list UI actually renders — nothing speculative. */
|
||||
export const LIST_FIELDS = [
|
||||
'activityDate',
|
||||
'addedDate',
|
||||
'bandwidthPriority',
|
||||
'corruptEver',
|
||||
'doneDate',
|
||||
'downloadDir',
|
||||
'downloadedEver',
|
||||
'downloadLimit',
|
||||
'downloadLimited',
|
||||
'error',
|
||||
'errorString',
|
||||
'eta',
|
||||
'file-count',
|
||||
'group',
|
||||
'haveValid',
|
||||
'honorsSessionLimits',
|
||||
'id',
|
||||
'isPrivate',
|
||||
'labels',
|
||||
'leftUntilDone',
|
||||
'magnetLink',
|
||||
'metadataPercentComplete',
|
||||
'name',
|
||||
'peer-limit',
|
||||
'peersGettingFromUs',
|
||||
'peersSendingToUs',
|
||||
'percentDone',
|
||||
'pieceCount',
|
||||
'pieceSize',
|
||||
'queuePosition',
|
||||
'rateDownload',
|
||||
'rateUpload',
|
||||
'secondsSeeding',
|
||||
'seedIdleLimit',
|
||||
'seedIdleMode',
|
||||
'seedRatioLimit',
|
||||
'seedRatioMode',
|
||||
'sequential_download',
|
||||
'sizeWhenDone',
|
||||
'status',
|
||||
'totalSize',
|
||||
'trackerList',
|
||||
'trackerStats',
|
||||
'uploadedEver',
|
||||
'uploadLimit',
|
||||
'uploadLimited',
|
||||
'uploadRatio',
|
||||
] as const;
|
||||
|
||||
/** Extra fields only the detail pane needs — big arrays we refuse to pull for every row on every poll. */
|
||||
export const DETAIL_FIELDS = [
|
||||
'comment',
|
||||
'creator',
|
||||
'dateCreated',
|
||||
'fileStats',
|
||||
'files',
|
||||
'hashString',
|
||||
'maxConnectedPeers',
|
||||
'peers',
|
||||
'peersFrom',
|
||||
'recheckProgress',
|
||||
] as const;
|
||||
@@ -0,0 +1,46 @@
|
||||
// Transmission upstream config for the officer-transmission sidecar.
|
||||
//
|
||||
// All knowledge of the Transmission daemon (its URL, its RPC path and its credentials) lives here,
|
||||
// mirroring the officer-slskd/officer-vault philosophy: the platform API is a thin auth+forward proxy and
|
||||
// holds NO Transmission credentials.
|
||||
|
||||
const { TRANSMISSION_URL, TRANSMISSION_USER, TRANSMISSION_PASS, TRANSMISSION_RPC_PATH } = process.env;
|
||||
|
||||
let warnedUnset = false;
|
||||
|
||||
/** The Transmission base URL (no trailing slash), or null when unconfigured (the sidecar then 503s). */
|
||||
export function getTransmissionBase(): string | null {
|
||||
const raw = TRANSMISSION_URL?.trim();
|
||||
if (!raw) {
|
||||
if (!warnedUnset) {
|
||||
console.warn('[transmission] TRANSMISSION_URL is unset — the sidecar will respond 503 until it is set');
|
||||
warnedUnset = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return raw.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The RPC endpoint path. Transmission serves it at /transmission/rpc by default, but a reverse proxy can
|
||||
* mount it anywhere, so it is configurable rather than hardcoded.
|
||||
*/
|
||||
export function getRpcPath(): string {
|
||||
const raw = TRANSMISSION_RPC_PATH?.trim();
|
||||
if (!raw) return '/transmission/rpc';
|
||||
return raw.startsWith('/') ? raw : `/${raw}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Authorization: Basic …` header value, or null when the daemon has no auth configured.
|
||||
*
|
||||
* Transmission treats an empty username as "no authentication required" — sending an empty Basic header in
|
||||
* that case is not merely useless, it makes the daemon reject the request. So this returns null unless a
|
||||
* username is actually set.
|
||||
*/
|
||||
export function getAuthHeader(): string | null {
|
||||
const user = TRANSMISSION_USER?.trim();
|
||||
if (!user) return null;
|
||||
const pass = TRANSMISSION_PASS ?? '';
|
||||
return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||
import { appRegistryMetas as musicMetas } from '../apps/Music';
|
||||
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
||||
import { appRegistryMetas as headscaleMetas } from '../apps/Headscale';
|
||||
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
|
||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||
import { useAppRegistry } from './useAppRegistry';
|
||||
import { useUserApps } from 'state/useUserApps';
|
||||
@@ -33,6 +34,7 @@ const apps = [
|
||||
...musicMetas,
|
||||
...soulseekMetas,
|
||||
...headscaleMetas,
|
||||
...transmissionMetas,
|
||||
...monitorMetas,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Plug, ShieldCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useMaintenance, useSession } from './useTransmissionData';
|
||||
|
||||
// The daemon's settings, as one form with a draft.
|
||||
//
|
||||
// Only the fields you actually changed are sent — session-set is a partial update, and posting the whole
|
||||
// object back would race the 5-second poll and re-apply stale values you never touched. The sidecar reads
|
||||
// the settings back after writing because Transmission clamps several of them, so the form snaps to what
|
||||
// the daemon really holds rather than to what you typed.
|
||||
|
||||
export const SettingsView = () => {
|
||||
const { session, isLoading, save } = useSession();
|
||||
const { portTest, updateBlocklist } = useMaintenance();
|
||||
const [draft, setDraft] = useState<Record<string, unknown>>({});
|
||||
|
||||
if (isLoading && !session) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading settings…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Could not read the session settings.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const get = <T,>(key: string, fallback: T): T =>
|
||||
key in draft ? (draft[key] as T) : ((session[key] as T) ?? fallback);
|
||||
const set = (key: string, value: unknown) => setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const dirty = Object.keys(draft).length > 0;
|
||||
|
||||
const apply = async () => {
|
||||
await save.mutateAsync(draft);
|
||||
setDraft({});
|
||||
};
|
||||
|
||||
const bool = (key: string, label: string, hint?: string) => (
|
||||
<Toggle checked={get(key, false)} onChange={(v) => set(key, v)} label={label} hint={hint} />
|
||||
);
|
||||
|
||||
const number = (key: string, label: string, unit?: string, min = 0) => (
|
||||
<Field label={label} unit={unit}>
|
||||
<Input
|
||||
type="number"
|
||||
min={min}
|
||||
value={String(get(key, 0))}
|
||||
onChange={(ev) => set(key, Number(ev.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
const text = (key: string, label: string, placeholder?: string) => (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`tr-${key}`}>{label}</Label>
|
||||
<Input
|
||||
id={`tr-${key}`}
|
||||
value={String(get(key, ''))}
|
||||
placeholder={placeholder}
|
||||
onChange={(ev) => set(key, ev.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<Tabs defaultValue="download" className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="mx-4 mt-3 w-fit shrink-0">
|
||||
<TabsTrigger value="download">Downloads</TabsTrigger>
|
||||
<TabsTrigger value="bandwidth">Bandwidth</TabsTrigger>
|
||||
<TabsTrigger value="network">Network</TabsTrigger>
|
||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||
<TabsTrigger value="other">Other</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<TabsContent value="download" className="mt-0 space-y-5">
|
||||
<Section title="Location">
|
||||
{text('download-dir', 'Download directory', '/downloads')}
|
||||
{bool('incomplete-dir-enabled', 'Keep incomplete files somewhere else')}
|
||||
{get('incomplete-dir-enabled', false) && text('incomplete-dir', 'Incomplete directory')}
|
||||
{bool(
|
||||
'rename-partial-files',
|
||||
'Append .part to incomplete files',
|
||||
'Stops media scanners picking up half-downloaded files.',
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="When adding">
|
||||
{bool('start-added-torrents', 'Start torrents as soon as they are added')}
|
||||
{bool('trash-original-torrent-files', 'Delete the .torrent file after adding')}
|
||||
</Section>
|
||||
|
||||
<Section title="Seeding limits">
|
||||
{bool('seedRatioLimited', 'Stop seeding at a ratio')}
|
||||
{get('seedRatioLimited', false) && (
|
||||
<Field label="Ratio">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={0}
|
||||
value={String(get('seedRatioLimit', 2))}
|
||||
onChange={(ev) => set('seedRatioLimit', Number(ev.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{bool('idle-seeding-limit-enabled', 'Stop seeding when idle')}
|
||||
{get('idle-seeding-limit-enabled', false) && number('idle-seeding-limit', 'Idle for', 'minutes')}
|
||||
</Section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bandwidth" className="mt-0 space-y-5">
|
||||
<Section title="Speed limits">
|
||||
{bool('speed-limit-down-enabled', 'Limit download speed')}
|
||||
{get('speed-limit-down-enabled', false) && number('speed-limit-down', 'Download', 'kB/s')}
|
||||
{bool('speed-limit-up-enabled', 'Limit upload speed')}
|
||||
{get('speed-limit-up-enabled', false) && number('speed-limit-up', 'Upload', 'kB/s')}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Alternative speed limits"
|
||||
hint="The turtle button in the toolbar switches to these without touching the limits above."
|
||||
>
|
||||
{bool('alt-speed-enabled', 'Use the alternative limits now')}
|
||||
{number('alt-speed-down', 'Download', 'kB/s')}
|
||||
{number('alt-speed-up', 'Upload', 'kB/s')}
|
||||
{bool('alt-speed-time-enabled', 'Switch automatically on a schedule')}
|
||||
{get('alt-speed-time-enabled', false) && (
|
||||
<>
|
||||
<TimeField
|
||||
label="From"
|
||||
minutes={get('alt-speed-time-begin', 0)}
|
||||
onChange={(v) => set('alt-speed-time-begin', v)}
|
||||
/>
|
||||
<TimeField
|
||||
label="To"
|
||||
minutes={get('alt-speed-time-end', 0)}
|
||||
onChange={(v) => set('alt-speed-time-end', v)}
|
||||
/>
|
||||
<DayPicker mask={get('alt-speed-time-day', 127)} onChange={(v) => set('alt-speed-time-day', v)} />
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="network" className="mt-0 space-y-5">
|
||||
<Section title="Listening port">
|
||||
{number('peer-port', 'Peer port', undefined, 1)}
|
||||
{bool('peer-port-random-on-start', 'Pick a random port on each start')}
|
||||
{bool('port-forwarding-enabled', 'Ask the router to forward it (UPnP / NAT-PMP)')}
|
||||
<div>
|
||||
<Button size="sm" variant="outline" onClick={() => portTest.mutate()} disabled={portTest.isPending}>
|
||||
{portTest.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plug className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Test port
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Peers">
|
||||
{number('peer-limit-global', 'Maximum peers overall', undefined, 1)}
|
||||
{number('peer-limit-per-torrent', 'Maximum peers per torrent', undefined, 1)}
|
||||
<Field label="Encryption">
|
||||
<Select value={String(get('encryption', 'preferred'))} onValueChange={(v) => set('encryption', v)}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tolerated">Allow unencrypted</SelectItem>
|
||||
<SelectItem value="preferred">Prefer encryption</SelectItem>
|
||||
<SelectItem value="required">Require encryption</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title="Peer discovery" hint="Private torrents ignore all of these by design.">
|
||||
{bool('pex-enabled', 'Peer exchange (PEX)')}
|
||||
{bool('dht-enabled', 'Distributed hash table (DHT)')}
|
||||
{bool('lpd-enabled', 'Local peer discovery')}
|
||||
{bool('utp-enabled', 'µTP — yields to other traffic on your connection')}
|
||||
</Section>
|
||||
|
||||
<Section title="Blocklist">
|
||||
{bool('blocklist-enabled', 'Use a blocklist')}
|
||||
{text('blocklist-url', 'Blocklist URL')}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => updateBlocklist.mutate()}
|
||||
disabled={updateBlocklist.isPending}
|
||||
>
|
||||
{updateBlocklist.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Update now
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{Number(session['blocklist-size'] ?? 0).toLocaleString()} rules loaded
|
||||
</span>
|
||||
</div>
|
||||
</Section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="queue" className="mt-0 space-y-5">
|
||||
<Section title="Download queue">
|
||||
{bool('download-queue-enabled', 'Limit how many torrents download at once')}
|
||||
{get('download-queue-enabled', false) && number('download-queue-size', 'At most', 'torrents', 1)}
|
||||
</Section>
|
||||
<Section title="Seed queue">
|
||||
{bool('seed-queue-enabled', 'Limit how many torrents seed at once')}
|
||||
{get('seed-queue-enabled', false) && number('seed-queue-size', 'At most', 'torrents', 1)}
|
||||
</Section>
|
||||
<Section
|
||||
title="Stalled torrents"
|
||||
hint="A stalled torrent gives up its queue slot so something else can run."
|
||||
>
|
||||
{bool('queue-stalled-enabled', 'Treat torrents with no activity as stalled')}
|
||||
{get('queue-stalled-enabled', false) && number('queue-stalled-minutes', 'After', 'minutes', 1)}
|
||||
</Section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="other" className="mt-0 space-y-5">
|
||||
<Section title="Performance">{number('cache-size-mb', 'Disk cache', 'MB')}</Section>
|
||||
|
||||
<Section title="On completion">
|
||||
{bool('script-torrent-done-enabled', 'Run a script when a torrent finishes')}
|
||||
{get('script-torrent-done-enabled', false) &&
|
||||
text('script-torrent-done-filename', 'Script path', '/etc/transmission/done.sh')}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Default trackers"
|
||||
hint="Added to every public torrent. One URL per line, blank line between tiers."
|
||||
>
|
||||
<Textarea
|
||||
rows={6}
|
||||
value={String(get('default-trackers', ''))}
|
||||
onChange={(ev) => set('default-trackers', ev.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="About">
|
||||
<dl className="grid gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
|
||||
<About label="Version" value={String(session.version ?? '—')} />
|
||||
<About label="RPC version" value={String(session['rpc-version'] ?? '—')} />
|
||||
<About label="Config directory" value={String(session['config-dir'] ?? '—')} />
|
||||
<About label="Session ID" value={String(session['session-id'] ?? '—')} />
|
||||
</dl>
|
||||
</Section>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
{/* The bar only exists when there is something to save — a permanently-visible disabled Save button
|
||||
teaches you to ignore it. */}
|
||||
{dirty && (
|
||||
<div className="flex shrink-0 items-center gap-3 border-t border-border bg-muted/40 px-4 py-2">
|
||||
<span className="flex-1 text-xs text-muted-foreground">
|
||||
{Object.keys(draft).length} unsaved change{Object.keys(draft).length === 1 ? '' : 's'}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => setDraft({})}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button size="sm" onClick={apply} disabled={save.isPending}>
|
||||
{save.isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Section = ({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) => (
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
{hint && <p className="mt-0.5 text-xs text-muted-foreground">{hint}</p>}
|
||||
<div className="mt-3 space-y-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const Toggle = ({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
hint,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}) => (
|
||||
<label className="flex items-start gap-2.5 text-sm">
|
||||
<Checkbox className="mt-0.5" checked={checked} onCheckedChange={(v) => onChange(v === true)} />
|
||||
<span>
|
||||
{label}
|
||||
{hint && <span className="mt-0.5 block text-xs text-muted-foreground">{hint}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
const Field = ({ label, unit, children }: { label: string; unit?: string; children: ReactNode }) => (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="w-56 shrink-0">{label}</span>
|
||||
{children}
|
||||
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Transmission stores the alt-speed schedule as minutes past midnight; the browser wants HH:MM. */
|
||||
const TimeField = ({
|
||||
label,
|
||||
minutes,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
minutes: number;
|
||||
onChange: (minutes: number) => void;
|
||||
}) => {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const value = `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
|
||||
return (
|
||||
<Field label={label}>
|
||||
<Input
|
||||
type="time"
|
||||
value={value}
|
||||
onChange={(ev) => {
|
||||
const [h, m] = ev.target.value.split(':').map(Number);
|
||||
if (Number.isFinite(h) && Number.isFinite(m)) onChange(h! * 60 + m!);
|
||||
}}
|
||||
className="w-32"
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
// alt-speed-time-day is a bitfield, Sunday = bit 0 (rpc-spec.md). 127 is every day.
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
const DayPicker = ({ mask, onChange }: { mask: number; onChange: (mask: number) => void }) => (
|
||||
<Field label="On days">
|
||||
<div className="flex gap-1">
|
||||
{DAYS.map((day, i) => {
|
||||
const bit = 1 << i;
|
||||
const on = (mask & bit) !== 0;
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => onChange(on ? mask & ~bit : mask | bit)}
|
||||
className={`rounded px-2 py-1 text-xs transition-colors ${
|
||||
on ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/70'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
|
||||
const About = ({ label, value }: { label: string; value: string }) => (
|
||||
<div className="flex min-w-0 items-baseline gap-3">
|
||||
<dt className="w-32 shrink-0 text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 flex-1 truncate font-mono" title={value}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { StatsBucket } from './shared';
|
||||
import { ArrowDownToLine, ArrowUpFromLine, HardDrive, Loader2 } from 'lucide-react';
|
||||
import { formatDuration, formatRatio, formatSize, formatSpeed } from './format';
|
||||
import { useFreeSpace, useSession, useSessionStats, useTorrents } from './useTransmissionData';
|
||||
|
||||
// Session statistics. Two columns because Transmission keeps two counters and conflating them is a classic
|
||||
// misreading — "this session" resets when the daemon restarts, "all time" never does.
|
||||
|
||||
export const StatsView = () => {
|
||||
const { stats, isLoading } = useSessionStats();
|
||||
const { session } = useSession();
|
||||
const { torrents } = useTorrents();
|
||||
const downloadDir = session?.['download-dir'];
|
||||
const { bytes: freeBytes } = useFreeSpace(downloadDir);
|
||||
|
||||
if (isLoading && !stats) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading statistics…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">No statistics.</div>;
|
||||
}
|
||||
|
||||
const totalSize = torrents.reduce((sum, t) => sum + (t.totalSize ?? 0), 0);
|
||||
const onDisk = torrents.reduce((sum, t) => sum + (t.haveValid ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Tile
|
||||
icon={<ArrowDownToLine className="h-4 w-4 text-blue-500" />}
|
||||
label="Download"
|
||||
value={formatSpeed(stats.downloadSpeed) || '0 B/s'}
|
||||
/>
|
||||
<Tile
|
||||
icon={<ArrowUpFromLine className="h-4 w-4 text-emerald-500" />}
|
||||
label="Upload"
|
||||
value={formatSpeed(stats.uploadSpeed) || '0 B/s'}
|
||||
/>
|
||||
<Tile label="Torrents" value={String(stats.torrentCount)} hint={`${stats.activeTorrentCount} active`} />
|
||||
<Tile
|
||||
icon={<HardDrive className="h-4 w-4 text-muted-foreground" />}
|
||||
label="Free space"
|
||||
value={freeBytes == null ? '—' : formatSize(freeBytes)}
|
||||
hint={downloadDir}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<Bucket title="This session" bucket={stats['current-stats']} />
|
||||
<Bucket title="All time" bucket={stats['cumulative-stats']} />
|
||||
</div>
|
||||
|
||||
<section className="mt-4 rounded-xl border border-border p-4">
|
||||
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Local library</h3>
|
||||
<dl className="grid gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
|
||||
<Row label="Torrents" value={String(torrents.length)} />
|
||||
<Row label="Paused" value={String(stats.pausedTorrentCount)} />
|
||||
<Row label="Total size" value={formatSize(totalSize)} />
|
||||
{/* haveValid, not totalSize: an unfinished or partially-wanted torrent occupies less than it
|
||||
claims, and the difference is the whole reason to look. */}
|
||||
<Row label="Verified on disk" value={formatSize(onDisk)} />
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Bucket = ({ title, bucket }: { title: string; bucket: StatsBucket }) => (
|
||||
<section className="rounded-xl border border-border p-4">
|
||||
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
<dl className="grid gap-x-6 gap-y-1.5 text-xs sm:grid-cols-2">
|
||||
<Row label="Downloaded" value={formatSize(bucket.downloadedBytes)} />
|
||||
<Row label="Uploaded" value={formatSize(bucket.uploadedBytes)} />
|
||||
<Row
|
||||
label="Ratio"
|
||||
value={formatRatio(bucket.downloadedBytes > 0 ? bucket.uploadedBytes / bucket.downloadedBytes : -1)}
|
||||
/>
|
||||
<Row label="Torrents added" value={bucket.filesAdded.toLocaleString()} />
|
||||
<Row label="Running time" value={formatDuration(bucket.secondsActive)} />
|
||||
<Row label="Sessions" value={bucket.sessionCount.toLocaleString()} />
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
|
||||
type TileProps = { label: string; value: string; hint?: string; icon?: ReactNode };
|
||||
|
||||
const Tile = ({ label, value, hint, icon }: TileProps) => (
|
||||
<div className="rounded-xl border border-border p-4">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums">{value}</div>
|
||||
{hint && (
|
||||
<div className="mt-0.5 truncate text-xs text-muted-foreground" title={hint}>
|
||||
{hint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Row = ({ label, value }: { label: string; value: string }) => (
|
||||
<div className="flex items-baseline gap-3">
|
||||
<dt className="w-32 shrink-0 text-muted-foreground">{label}</dt>
|
||||
<dd className="tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { statusLabel, statusTone, TONE_CLASSES } from './format';
|
||||
import { useTorrentDetail } from './useTransmissionData';
|
||||
import { GeneralTab } from './detail/GeneralTab';
|
||||
import { FilesTab } from './detail/FilesTab';
|
||||
import { PeersTab } from './detail/PeersTab';
|
||||
import { TrackersTab } from './detail/TrackersTab';
|
||||
import { TrackersDialog } from './dialogs/TrackersDialog';
|
||||
|
||||
// The bottom pane of the torrents section, driven by ?selected= in the URL.
|
||||
//
|
||||
// It fetches its own torrent rather than being handed the row from the list: the list poll deliberately
|
||||
// omits files, peers and peersFrom, and threading two shapes of the same object through the tree is how
|
||||
// you end up rendering a stale peer list.
|
||||
|
||||
type TorrentDetailProps = { id: number; onClose: () => void };
|
||||
|
||||
export const TorrentDetail = ({ id, onClose }: TorrentDetailProps) => {
|
||||
const { torrent, isLoading } = useTorrentDetail(id);
|
||||
const [editingTrackers, setEditingTrackers] = useState(false);
|
||||
|
||||
if (isLoading && !torrent) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!torrent) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">That torrent is gone.</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tone = TONE_CLASSES[statusTone(torrent)];
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col border-t border-border">
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${tone.dot}`} />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={torrent.name}>
|
||||
{torrent.name}
|
||||
</span>
|
||||
<span className={`shrink-0 text-xs ${tone.text}`}>{statusLabel(torrent)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Close details"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="general" className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="mx-3 mt-2 w-fit shrink-0">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="files">Files{torrent.files ? ` (${torrent.files.length})` : ''}</TabsTrigger>
|
||||
<TabsTrigger value="peers">Peers{torrent.peers ? ` (${torrent.peers.length})` : ''}</TabsTrigger>
|
||||
<TabsTrigger value="trackers">
|
||||
Trackers{torrent.trackerStats ? ` (${torrent.trackerStats.length})` : ''}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* mt-0 undoes the Tabs default spacing; these panes own their own scrolling. */}
|
||||
<TabsContent value="general" className="mt-0 min-h-0 flex-1 overflow-auto">
|
||||
<GeneralTab torrent={torrent} />
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="mt-0 min-h-0 flex-1">
|
||||
<FilesTab torrent={torrent} />
|
||||
</TabsContent>
|
||||
<TabsContent value="peers" className="mt-0 min-h-0 flex-1">
|
||||
<PeersTab torrent={torrent} />
|
||||
</TabsContent>
|
||||
<TabsContent value="trackers" className="mt-0 min-h-0 flex-1">
|
||||
<TrackersTab torrent={torrent} onEdit={() => setEditingTrackers(true)} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<TrackersDialog open={editingTrackers} onOpenChange={setEditingTrackers} torrent={torrent} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { Torrent } from './shared';
|
||||
import type { ColumnDef, ColumnKey } from './columns';
|
||||
import type { SortKey } from './useTorrentFilters';
|
||||
import { useRef } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ArrowDown, ArrowUp, Check } from 'lucide-react';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import { ALL_COLUMNS, PINNED_COLUMN } from './columns';
|
||||
|
||||
// The torrent list. Virtualised because the row count is unbounded and the whole thing re-renders on a
|
||||
// 5-second poll — 43 rows is fine, 4000 is not, and a torrent client accumulates.
|
||||
//
|
||||
// Layout is a fixed-width grid inside a single horizontal scroller shared by the header and the body, so
|
||||
// the two cannot drift apart the way two independently-scrolled elements do.
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
|
||||
type TorrentTableProps = {
|
||||
torrents: Torrent[];
|
||||
columns: ColumnDef[];
|
||||
/** Multi-selection, for the bulk actions in the toolbar and the row context menu. */
|
||||
selection: Set<number>;
|
||||
/** The one torrent whose detail pane is open (?selected=) — a different thing from being in `selection`. */
|
||||
focused: number | null;
|
||||
onRowClick: (torrent: Torrent, ev: MouseEvent) => void;
|
||||
sort: SortKey;
|
||||
desc: boolean;
|
||||
onSort: (key: SortKey) => void;
|
||||
visibleKeys: ColumnKey[];
|
||||
onToggleColumn: (key: ColumnKey) => void;
|
||||
onResetColumns: () => void;
|
||||
/** Right-clicking a row selects it if it isn't already, then opens the actions menu. */
|
||||
onRowContextMenu: (torrent: Torrent) => void;
|
||||
rowMenu: (torrent: Torrent) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const TorrentTable = ({
|
||||
torrents,
|
||||
columns,
|
||||
selection,
|
||||
focused,
|
||||
onRowClick,
|
||||
sort,
|
||||
desc,
|
||||
onSort,
|
||||
visibleKeys,
|
||||
onToggleColumn,
|
||||
onResetColumns,
|
||||
onRowContextMenu,
|
||||
rowMenu,
|
||||
}: TorrentTableProps) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: torrents.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 12,
|
||||
});
|
||||
|
||||
const gridTemplate = columns.map((c) => `${c.width}px`).join(' ');
|
||||
const totalWidth = columns.reduce((sum, c) => sum + c.width, 0);
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="h-full overflow-auto">
|
||||
{/* min-w on an inline-block wrapper: the header must be as wide as the widest row so it keeps
|
||||
scrolling with the body rather than clipping at the viewport edge. */}
|
||||
<div style={{ minWidth: totalWidth }}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="sticky top-0 z-10 grid border-b border-border bg-background/95 backdrop-blur"
|
||||
style={{ gridTemplateColumns: gridTemplate }}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<button
|
||||
key={col.key}
|
||||
type="button"
|
||||
disabled={!col.sortKey}
|
||||
onClick={() => col.sortKey && onSort(col.sortKey)}
|
||||
className={`flex items-center gap-1 overflow-hidden px-2 py-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground ${
|
||||
col.sortKey ? 'hover:text-foreground' : 'cursor-default'
|
||||
} ${col.align === 'right' ? 'justify-end' : col.align === 'center' ? 'justify-center' : ''}`}
|
||||
>
|
||||
<span className="truncate">{col.label}</span>
|
||||
{col.sortKey === sort &&
|
||||
(desc ? <ArrowDown className="h-3 w-3 shrink-0" /> : <ArrowUp className="h-3 w-3 shrink-0" />)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="max-h-[70vh] w-56 overflow-y-auto">
|
||||
{ALL_COLUMNS.map((col) => {
|
||||
const shown = visibleKeys.includes(col.key);
|
||||
const pinned = col.key === PINNED_COLUMN;
|
||||
return (
|
||||
<ContextMenuItem
|
||||
key={col.key}
|
||||
disabled={pinned}
|
||||
onSelect={(ev) => {
|
||||
// Keep the menu open — picking columns is a several-clicks job and reopening it each
|
||||
// time, at the same mouse position, is a small torture.
|
||||
ev.preventDefault();
|
||||
onToggleColumn(col.key);
|
||||
}}
|
||||
>
|
||||
<Check className={`mr-2 h-3.5 w-3.5 ${shown ? '' : 'opacity-0'}`} />
|
||||
{col.label}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
})}
|
||||
<ContextMenuItem onSelect={onResetColumns}>Reset to defaults</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<div className="relative" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((row) => {
|
||||
const torrent = torrents[row.index];
|
||||
if (!torrent) return null;
|
||||
const isSelected = selection.has(torrent.id);
|
||||
const isFocused = focused === torrent.id;
|
||||
|
||||
return (
|
||||
<ContextMenu key={torrent.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
onClick={(ev) => onRowClick(torrent, ev)}
|
||||
onContextMenu={() => onRowContextMenu(torrent)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: row.size,
|
||||
transform: `translateY(${row.start}px)`,
|
||||
gridTemplateColumns: gridTemplate,
|
||||
}}
|
||||
className={`grid cursor-default items-center border-b border-border/40 text-xs ${
|
||||
isSelected ? 'bg-primary/10' : 'hover:bg-muted/50'
|
||||
} ${isFocused ? 'ring-1 ring-inset ring-primary/40' : ''}`}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`min-w-0 overflow-hidden whitespace-nowrap px-2 ${
|
||||
col.align === 'right'
|
||||
? 'text-right tabular-nums'
|
||||
: col.align === 'center'
|
||||
? 'text-center'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{col.render(torrent)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-56">{rowMenu(torrent)}</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { Torrent, TorrentAction } from './shared';
|
||||
import {
|
||||
ChevronsDown,
|
||||
ChevronsUp,
|
||||
CircleCheckBig,
|
||||
FolderInput,
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
Rabbit,
|
||||
Tag,
|
||||
Trash2,
|
||||
Turtle,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
// Actions above the list. Everything acts on the current selection except Add and the turtle toggle; with
|
||||
// nothing selected the selection-scoped buttons are disabled rather than silently acting on all torrents,
|
||||
// which is the single most destructive mistake a torrent client UI can make.
|
||||
|
||||
type TorrentToolbarProps = {
|
||||
selected: Torrent[];
|
||||
search: string;
|
||||
onSearch: (value: string) => void;
|
||||
onAct: (action: TorrentAction) => void;
|
||||
onAdd: () => void;
|
||||
onRemove: () => void;
|
||||
onMove: () => void;
|
||||
onLabels: () => void;
|
||||
onOptions: () => void;
|
||||
/** Session alt-speed ("turtle mode"), which is global rather than per-torrent. */
|
||||
altSpeed: boolean;
|
||||
onToggleAltSpeed: () => void;
|
||||
total: number;
|
||||
shown: number;
|
||||
};
|
||||
|
||||
export const TorrentToolbar = ({
|
||||
selected,
|
||||
search,
|
||||
onSearch,
|
||||
onAct,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onMove,
|
||||
onLabels,
|
||||
onOptions,
|
||||
altSpeed,
|
||||
onToggleAltSpeed,
|
||||
total,
|
||||
shown,
|
||||
}: TorrentToolbarProps) => {
|
||||
const none = selected.length === 0;
|
||||
// "Start" and "Pause" are offered by what the selection can actually do, so a paused selection doesn't
|
||||
// show a live Pause button that would be a no-op.
|
||||
const anyStopped = selected.some((t) => t.status === 0);
|
||||
const anyRunning = selected.some((t) => t.status !== 0);
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-1 border-b border-border px-3 py-2">
|
||||
<Button size="sm" onClick={onAdd}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Add
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
<IconButton label="Start" icon={Play} disabled={none || !anyStopped} onClick={() => onAct('start')} />
|
||||
<IconButton label="Pause" icon={Pause} disabled={none || !anyRunning} onClick={() => onAct('stop')} />
|
||||
<IconButton label="Verify local data" icon={CircleCheckBig} disabled={none} onClick={() => onAct('verify')} />
|
||||
<IconButton
|
||||
label="Ask tracker for more peers"
|
||||
icon={RefreshCw}
|
||||
disabled={none}
|
||||
onClick={() => onAct('reannounce')}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<IconButton label="Move up the queue" icon={ChevronsUp} disabled={none} onClick={() => onAct('queue-up')} />
|
||||
<IconButton label="Move down the queue" icon={ChevronsDown} disabled={none} onClick={() => onAct('queue-down')} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<IconButton label="Set location" icon={FolderInput} disabled={none} onClick={onMove} />
|
||||
<IconButton label="Labels" icon={Tag} disabled={none} onClick={onLabels} />
|
||||
<IconButton label="Torrent options" icon={Settings2} disabled={none} onClick={onOptions} />
|
||||
<IconButton label="Remove" icon={Trash2} disabled={none} onClick={onRemove} destructive />
|
||||
|
||||
<Separator />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-xs">
|
||||
More
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem disabled={none} onSelect={() => onAct('start-now')}>
|
||||
Start now (skip the queue)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={none} onSelect={() => onAct('queue-top')}>
|
||||
Move to the top of the queue
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={none} onSelect={() => onAct('queue-bottom')}>
|
||||
Move to the bottom of the queue
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<IconButton
|
||||
label={altSpeed ? 'Alternative speed limits are on' : 'Turn on alternative speed limits'}
|
||||
icon={altSpeed ? Turtle : Rabbit}
|
||||
onClick={onToggleAltSpeed}
|
||||
active={altSpeed}
|
||||
/>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{selected.length > 0 && (
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{selected.length} selected</span>
|
||||
)}
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{shown === total ? `${total}` : `${shown} / ${total}`}
|
||||
</span>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(ev) => onSearch(ev.target.value)}
|
||||
placeholder="Filter by name or label"
|
||||
className="h-8 w-56 pl-7 pr-7 text-xs"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Separator = () => <span className="mx-1 h-5 w-px bg-border" />;
|
||||
|
||||
type IconButtonProps = {
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
const IconButton = ({ label, icon: Icon, onClick, disabled, destructive, active }: IconButtonProps) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
className={`h-8 w-8 p-0 ${destructive ? 'hover:text-destructive' : ''} ${active ? 'bg-muted text-primary' : ''}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
import type { Torrent, TorrentAction } from './shared';
|
||||
import type { ColumnKey } from './columns';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Loader2, PackageOpen } from 'lucide-react';
|
||||
import { useLocalStorageState } from 'hooks';
|
||||
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
|
||||
import { COLUMN_BY_KEY, DEFAULT_VISIBLE_COLUMNS, PINNED_COLUMN } from './columns';
|
||||
import { buildFacets, selectTorrents, useTorrentFilters } from './useTorrentFilters';
|
||||
import { useSession, useTorrentMutations, useTorrents } from './useTransmissionData';
|
||||
import { TorrentToolbar } from './TorrentToolbar';
|
||||
import { TorrentTable } from './TorrentTable';
|
||||
import { TorrentDetail } from './TorrentDetail';
|
||||
import { AddTorrentDialog } from './dialogs/AddTorrentDialog';
|
||||
import { RemoveTorrentDialog } from './dialogs/RemoveTorrentDialog';
|
||||
import { MoveTorrentDialog } from './dialogs/MoveTorrentDialog';
|
||||
import { LabelsDialog } from './dialogs/LabelsDialog';
|
||||
import { TorrentOptionsDialog } from './dialogs/TorrentOptionsDialog';
|
||||
|
||||
// The torrents section: toolbar, list, and the detail pane for ?selected=.
|
||||
//
|
||||
// Two kinds of selection live here and they are deliberately different things:
|
||||
// • the multi-selection (local state) is what the toolbar and the context menu act on. It is ephemeral —
|
||||
// a checkbox state, not an address, and putting forty ids in the query string would be absurd.
|
||||
// • ?selected= is the one torrent whose detail pane is open. That IS an address: it survives a reload
|
||||
// and can be sent to yourself.
|
||||
|
||||
const COLUMNS_STORAGE_KEY = 'transmission:columns';
|
||||
/** How much of the pane the detail takes when open. */
|
||||
const DETAIL_HEIGHT = '45%';
|
||||
|
||||
export const TorrentsView = () => {
|
||||
const { torrents, isLoading, error } = useTorrents();
|
||||
const { session, save } = useSession();
|
||||
const { act } = useTorrentMutations();
|
||||
const { filters, setSearch, setSort, selected, setSelected } = useTorrentFilters();
|
||||
|
||||
const [visibleKeys, setVisibleKeys] = useLocalStorageState<ColumnKey[]>(COLUMNS_STORAGE_KEY, DEFAULT_VISIBLE_COLUMNS);
|
||||
const [selection, setSelection] = useState<Set<number>>(() => new Set());
|
||||
const anchorRef = useRef<number | null>(null);
|
||||
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [moving, setMoving] = useState(false);
|
||||
const [labelling, setLabelling] = useState(false);
|
||||
const [optioning, setOptioning] = useState(false);
|
||||
|
||||
const rows = useMemo(() => selectTorrents(torrents, filters), [torrents, filters]);
|
||||
const facets = useMemo(() => buildFacets(torrents), [torrents]);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
// Ordered by the catalogue, not by the stored array — a saved order from an older column set would
|
||||
// otherwise silently drop the columns added since.
|
||||
[...visibleKeys].map((key) => COLUMN_BY_KEY.get(key)).filter((c): c is NonNullable<typeof c> => !!c),
|
||||
[visibleKeys],
|
||||
);
|
||||
|
||||
const byId = useMemo(() => new Map(torrents.map((t) => [t.id, t])), [torrents]);
|
||||
const selectedTorrents = useMemo(
|
||||
() => [...selection].map((id) => byId.get(id)).filter((t): t is Torrent => !!t),
|
||||
[selection, byId],
|
||||
);
|
||||
|
||||
const onRowClick = useCallback(
|
||||
(torrent: Torrent, ev: MouseEvent) => {
|
||||
const index = rows.findIndex((r) => r.id === torrent.id);
|
||||
|
||||
if (ev.shiftKey && anchorRef.current != null) {
|
||||
const anchorIndex = rows.findIndex((r) => r.id === anchorRef.current);
|
||||
if (anchorIndex >= 0) {
|
||||
const [from, to] = anchorIndex < index ? [anchorIndex, index] : [index, anchorIndex];
|
||||
setSelection(new Set(rows.slice(from, to + 1).map((r) => r.id)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.ctrlKey || ev.metaKey) {
|
||||
setSelection((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(torrent.id)) next.delete(torrent.id);
|
||||
else next.add(torrent.id);
|
||||
return next;
|
||||
});
|
||||
anchorRef.current = torrent.id;
|
||||
return;
|
||||
}
|
||||
|
||||
setSelection(new Set([torrent.id]));
|
||||
anchorRef.current = torrent.id;
|
||||
// A plain click both selects and opens — the detail pane is the point of clicking a row.
|
||||
setSelected(torrent.id);
|
||||
},
|
||||
[rows, setSelected],
|
||||
);
|
||||
|
||||
const onRowContextMenu = useCallback((torrent: Torrent) => {
|
||||
// Right-clicking outside the selection replaces it. Right-clicking inside keeps it, so a menu action on
|
||||
// a 20-row selection doesn't silently collapse to the one row under the cursor.
|
||||
setSelection((prev) => (prev.has(torrent.id) ? prev : new Set([torrent.id])));
|
||||
anchorRef.current = torrent.id;
|
||||
}, []);
|
||||
|
||||
const runAction = useCallback(
|
||||
(action: TorrentAction) => {
|
||||
if (selection.size === 0) return;
|
||||
act.mutate({ ids: [...selection], action });
|
||||
},
|
||||
[act, selection],
|
||||
);
|
||||
|
||||
const toggleColumn = useCallback(
|
||||
(key: ColumnKey) => {
|
||||
if (key === PINNED_COLUMN) return;
|
||||
setVisibleKeys((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
|
||||
},
|
||||
[setVisibleKeys],
|
||||
);
|
||||
|
||||
const altSpeed = session?.['alt-speed-enabled'] === true;
|
||||
|
||||
if (isLoading && torrents.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading torrents…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
|
||||
<div className="text-sm font-medium">Cannot reach Transmission</div>
|
||||
<p className="max-w-md text-xs text-muted-foreground">
|
||||
The officer-transmission sidecar answered with an error. Check that the daemon is running and that
|
||||
TRANSMISSION_URL points at it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<TorrentToolbar
|
||||
selected={selectedTorrents}
|
||||
search={filters.q}
|
||||
onSearch={setSearch}
|
||||
onAct={runAction}
|
||||
onAdd={() => setAdding(true)}
|
||||
onRemove={() => selection.size > 0 && setRemoving(true)}
|
||||
onMove={() => selection.size > 0 && setMoving(true)}
|
||||
onLabels={() => selection.size > 0 && setLabelling(true)}
|
||||
onOptions={() => selection.size > 0 && setOptioning(true)}
|
||||
altSpeed={altSpeed}
|
||||
onToggleAltSpeed={() => save.mutate({ 'alt-speed-enabled': !altSpeed })}
|
||||
total={torrents.length}
|
||||
shown={rows.length}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{rows.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<PackageOpen className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold">{filters.isFiltered ? 'Nothing matches' : 'No torrents'}</div>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
{filters.isFiltered
|
||||
? 'Clear the filters in the sidebar to see everything again.'
|
||||
: 'Add a magnet link or a .torrent file to get started.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TorrentTable
|
||||
torrents={rows}
|
||||
columns={columns}
|
||||
selection={selection}
|
||||
focused={selected}
|
||||
onRowClick={onRowClick}
|
||||
sort={filters.sort}
|
||||
desc={filters.desc}
|
||||
onSort={(key) => setSort(key, filters.sort, filters.desc)}
|
||||
visibleKeys={visibleKeys}
|
||||
onToggleColumn={toggleColumn}
|
||||
onResetColumns={() => setVisibleKeys(DEFAULT_VISIBLE_COLUMNS)}
|
||||
onRowContextMenu={onRowContextMenu}
|
||||
rowMenu={() => (
|
||||
<>
|
||||
<ContextMenuItem onSelect={() => runAction('start')}>Start</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => runAction('start-now')}>Start now</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => runAction('stop')}>Pause</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => runAction('verify')}>Verify local data</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => runAction('reannounce')}>Ask for more peers</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => runAction('queue-top')}>Move to top of queue</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => runAction('queue-up')}>Move up</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => runAction('queue-down')}>Move down</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => runAction('queue-bottom')}>Move to bottom</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => setMoving(true)}>Set location…</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => setLabelling(true)}>Labels…</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => setOptioning(true)}>Torrent options…</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem className="text-destructive" onSelect={() => setRemoving(true)}>
|
||||
Remove…
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected != null && (
|
||||
<div style={{ height: DETAIL_HEIGHT }} className="min-h-0 shrink-0">
|
||||
<TorrentDetail id={selected} onClose={() => setSelected(null)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddTorrentDialog open={adding} onOpenChange={setAdding} />
|
||||
<RemoveTorrentDialog
|
||||
open={removing}
|
||||
onOpenChange={setRemoving}
|
||||
targets={selectedTorrents}
|
||||
onRemoved={() => {
|
||||
setSelection(new Set());
|
||||
// The detail pane would keep polling a torrent that no longer exists and settle on "that torrent
|
||||
// is gone" — closing it is both correct and less alarming.
|
||||
if (selected != null && selection.has(selected)) setSelected(null);
|
||||
}}
|
||||
/>
|
||||
<MoveTorrentDialog
|
||||
open={moving}
|
||||
onOpenChange={setMoving}
|
||||
targets={selectedTorrents}
|
||||
knownDirs={facets.dirs.map((d) => d.value)}
|
||||
/>
|
||||
<LabelsDialog
|
||||
open={labelling}
|
||||
onOpenChange={setLabelling}
|
||||
targets={selectedTorrents}
|
||||
known={facets.labels.map((l) => l.value)}
|
||||
/>
|
||||
<TorrentOptionsDialog open={optioning} onOpenChange={setOptioning} targets={selectedTorrents} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { FacetEntry } from './useTorrentFilters';
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router';
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
Folder,
|
||||
Gauge,
|
||||
ListChecks,
|
||||
Radio,
|
||||
Settings,
|
||||
Tag,
|
||||
Waves,
|
||||
} from 'lucide-react';
|
||||
import { TRANSMISSION_SECTIONS, transmissionSectionPath, type TransmissionSectionId } from './shared';
|
||||
import { formatSpeed } from './format';
|
||||
import { useSessionStats, useTorrents } from './useTransmissionData';
|
||||
import { buildFacets, countByStatus, STATUS_FILTERS, useTorrentFilters } from './useTorrentFilters';
|
||||
|
||||
// Left panel of /transmission: live throughput on top, the three sections, then the facet filters.
|
||||
//
|
||||
// Sections are real links (cmd-click, back button, reload) with active state from react-router's NavLink.
|
||||
// Filters are buttons because they write to the query string of the page you are already on — they are
|
||||
// still addressable, just not a different route.
|
||||
//
|
||||
// The facet lists are derived from the torrents already in the cache, not fetched separately: Transmission
|
||||
// has no "list my labels" RPC, and asking for one would mean a second poll returning the same data.
|
||||
|
||||
const ICONS: Record<TransmissionSectionId, LucideIcon> = {
|
||||
torrents: ListChecks,
|
||||
stats: Gauge,
|
||||
settings: Settings,
|
||||
};
|
||||
|
||||
const ROW = 'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors';
|
||||
|
||||
export const TransmissionNav = () => {
|
||||
const { torrents } = useTorrents();
|
||||
const { stats } = useSessionStats();
|
||||
const { filters, setStatus, toggle, clearFilters } = useTorrentFilters();
|
||||
|
||||
const facets = buildFacets(torrents);
|
||||
const counts = countByStatus(torrents);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<div className="flex items-center gap-3 px-4 py-4">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-amber-500/15 text-amber-500 ring-1 ring-black/5">
|
||||
<Waves className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold leading-tight">Transmission</div>
|
||||
<div className="flex items-center gap-2.5 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1 tabular-nums">
|
||||
<ArrowDownToLine className="h-3 w-3 text-blue-500" />
|
||||
{formatSpeed(stats?.downloadSpeed) || '0'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 tabular-nums">
|
||||
<ArrowUpFromLine className="h-3 w-3 text-emerald-500" />
|
||||
{formatSpeed(stats?.uploadSpeed) || '0'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-0.5 px-2 pb-3">
|
||||
{TRANSMISSION_SECTIONS.map(({ id, label }) => {
|
||||
const Icon = ICONS[id];
|
||||
return (
|
||||
<NavLink
|
||||
key={id}
|
||||
to={transmissionSectionPath(id)}
|
||||
className={({ isActive }) =>
|
||||
`${ROW} ${
|
||||
isActive
|
||||
? 'bg-primary/10 font-medium text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && (
|
||||
<span className="absolute left-0 top-1/2 h-5 w-1 -translate-y-1/2 rounded-r-full bg-primary" />
|
||||
)}
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'}`}
|
||||
/>
|
||||
<span className="flex-1">{label}</span>
|
||||
{id === 'torrents' && torrents.length > 0 && (
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{torrents.length}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center justify-between px-5 pb-1 pt-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Filter</span>
|
||||
{filters.isFiltered && (
|
||||
<button type="button" onClick={clearFilters} className="text-[10px] font-medium text-primary hover:underline">
|
||||
clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5 px-2 pb-2">
|
||||
{STATUS_FILTERS.map(({ id, label }) => (
|
||||
<FilterRow
|
||||
key={id}
|
||||
label={label}
|
||||
count={counts[id]}
|
||||
active={filters.status === id}
|
||||
onClick={() => setStatus(id)}
|
||||
/>
|
||||
))}
|
||||
{facets.errors > 0 && (
|
||||
<FilterRow
|
||||
icon={CircleAlert}
|
||||
label="Tracker errors"
|
||||
count={facets.errors}
|
||||
active={filters.errorOnly}
|
||||
tone="text-amber-500"
|
||||
onClick={() => toggle('error', '1', filters.errorOnly ? '1' : null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FacetGroup
|
||||
title="Labels"
|
||||
icon={Tag}
|
||||
entries={facets.labels}
|
||||
selected={filters.label}
|
||||
onPick={(v) => toggle('label', v, filters.label)}
|
||||
/>
|
||||
<FacetGroup
|
||||
title="Trackers"
|
||||
icon={Radio}
|
||||
entries={facets.trackers}
|
||||
selected={filters.tracker}
|
||||
onPick={(v) => toggle('tracker', v, filters.tracker)}
|
||||
/>
|
||||
<FacetGroup
|
||||
title="Locations"
|
||||
icon={Folder}
|
||||
entries={facets.dirs}
|
||||
selected={filters.downloadDir}
|
||||
onPick={(v) => toggle('dir', v, filters.downloadDir)}
|
||||
// A path is only distinguishable by its tail, and the panel is 22% of the window wide.
|
||||
renderLabel={(v) => v.split('/').filter(Boolean).slice(-1)[0] || v}
|
||||
/>
|
||||
|
||||
<div className="mt-auto px-4 py-3 text-[10px] text-muted-foreground">
|
||||
{stats ? `${stats.activeTorrentCount} active · ${stats.pausedTorrentCount} paused` : 'connecting…'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type FilterRowProps = {
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon?: LucideIcon;
|
||||
tone?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const FilterRow = ({ label, count, active, onClick, icon: Icon, tone, title }: FilterRowProps) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title ?? label}
|
||||
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-left text-xs transition-colors ${
|
||||
active ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{Icon && <Icon className={`h-3.5 w-3.5 shrink-0 ${tone ?? ''}`} />}
|
||||
<span className={`min-w-0 flex-1 truncate ${tone ?? ''}`}>{label}</span>
|
||||
<span className="shrink-0 tabular-nums opacity-60">{count}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
type FacetGroupProps = {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
entries: FacetEntry[];
|
||||
selected: string | null;
|
||||
onPick: (value: string) => void;
|
||||
renderLabel?: (value: string) => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapsible because these lists are open-ended — one tracker per site, one label per whim. Collapsed by
|
||||
* default unless something in the group is selected, so arriving on a shared filtered URL shows you why.
|
||||
*/
|
||||
const FacetGroup = ({ title, icon: Icon, entries, selected, onPick, renderLabel }: FacetGroupProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const expanded = open || selected != null;
|
||||
|
||||
if (!entries.length) return null;
|
||||
|
||||
return (
|
||||
<div className="px-2 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
<span className="flex-1">{title}</span>
|
||||
<span className="tabular-nums opacity-60">{entries.length}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-0.5 flex flex-col gap-0.5">
|
||||
{entries.map(({ value, count }) => (
|
||||
<FilterRow
|
||||
key={value}
|
||||
label={renderLabel ? renderLabel(value) : value}
|
||||
title={value}
|
||||
count={count}
|
||||
active={selected === value}
|
||||
onClick={() => onPick(value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useTransmissionSection } from './useTransmissionSection';
|
||||
import { TorrentsView } from './TorrentsView';
|
||||
import { StatsView } from './StatsView';
|
||||
import { SettingsView } from './SettingsView';
|
||||
|
||||
// Right panel of the /transmission workspace — renders the section named by the URL.
|
||||
|
||||
export const TransmissionView = () => {
|
||||
const section = useTransmissionSection();
|
||||
|
||||
switch (section) {
|
||||
case 'stats':
|
||||
return <StatsView />;
|
||||
case 'settings':
|
||||
return <SettingsView />;
|
||||
default:
|
||||
return <TorrentsView />;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ArrowDownToLine, ArrowUpFromLine, Waves } from 'lucide-react';
|
||||
import { TRANSMISSION_SECTIONS } from './shared';
|
||||
import { formatSpeed } from './format';
|
||||
import { useTransmissionSection } from './useTransmissionSection';
|
||||
import { useSession, useSessionStats } from './useTransmissionData';
|
||||
|
||||
// Panel header for the right (transmission-view) panel: which section, which daemon version, and the live
|
||||
// totals — the one number you want visible no matter which section you are looking at.
|
||||
|
||||
export const TransmissionViewHeader = () => {
|
||||
const section = useTransmissionSection();
|
||||
const { stats } = useSessionStats();
|
||||
const { session } = useSession();
|
||||
const label = TRANSMISSION_SECTIONS.find((s) => s.id === section)?.label ?? 'Transmission';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Waves className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-xs font-medium">
|
||||
{label}
|
||||
{session?.version && <span className="ml-1.5 font-normal text-black/50">· {session.version}</span>}
|
||||
</span>
|
||||
{stats && (
|
||||
<span className="flex shrink-0 items-center gap-2 text-[10px] tabular-nums text-black/60">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowDownToLine className="h-3 w-3" />
|
||||
{formatSpeed(stats.downloadSpeed) || '0'}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<ArrowUpFromLine className="h-3 w-3" />
|
||||
{formatSpeed(stats.uploadSpeed) || '0'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,310 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Torrent } from './shared';
|
||||
import type { SortKey } from './useTorrentFilters';
|
||||
import { Lock } from 'lucide-react';
|
||||
import {
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatEta,
|
||||
formatPercent,
|
||||
formatRatio,
|
||||
formatSize,
|
||||
formatSpeed,
|
||||
PRIORITY_LABELS,
|
||||
statusLabel,
|
||||
statusTone,
|
||||
TONE_CLASSES,
|
||||
} from './format';
|
||||
|
||||
// Column catalogue for the torrent table. Mirrors the reference client's set, minus the columns that only
|
||||
// exist there because its canvas renderer needed them split (uploadedDownloaded, the separate seeds cell).
|
||||
//
|
||||
// Widths are fixed px rather than fractions: the table scrolls horizontally and is virtualised vertically,
|
||||
// so a row has to be laid out without measuring, and a percentage-width grid inside a horizontal scroller
|
||||
// collapses to the viewport instead of the content.
|
||||
|
||||
export type ColumnKey =
|
||||
| 'name'
|
||||
| 'status'
|
||||
| 'percentDone'
|
||||
| 'totalSize'
|
||||
| 'sizeWhenDone'
|
||||
| 'leftUntilDone'
|
||||
| 'haveValid'
|
||||
| 'downloadedEver'
|
||||
| 'uploadedEver'
|
||||
| 'rateDownload'
|
||||
| 'rateUpload'
|
||||
| 'eta'
|
||||
| 'uploadRatio'
|
||||
| 'peersSendingToUs'
|
||||
| 'peersGettingFromUs'
|
||||
| 'addedDate'
|
||||
| 'doneDate'
|
||||
| 'activityDate'
|
||||
| 'secondsSeeding'
|
||||
| 'downloadDir'
|
||||
| 'officerTrackerHost'
|
||||
| 'officerTrackerStatus'
|
||||
| 'bandwidthPriority'
|
||||
| 'queuePosition'
|
||||
| 'labels'
|
||||
| 'isPrivate'
|
||||
| 'group'
|
||||
| 'file-count'
|
||||
| 'pieceCount'
|
||||
| 'id';
|
||||
|
||||
export type ColumnDef = {
|
||||
key: ColumnKey;
|
||||
label: string;
|
||||
width: number;
|
||||
align?: 'right' | 'center';
|
||||
/** Absent when the column has no meaningful order (nothing here, currently — kept for honesty). */
|
||||
sortKey?: SortKey;
|
||||
render: (t: Torrent) => ReactNode;
|
||||
};
|
||||
|
||||
const num = (v: number | undefined) => (v == null ? '—' : v.toLocaleString());
|
||||
/** A count of zero is noise in a dense table — the absence of peers is already visible as a blank. */
|
||||
const positive = (v: number | undefined) => (v ? v.toLocaleString() : '');
|
||||
|
||||
const ProgressCell = ({ torrent }: { torrent: Torrent }) => {
|
||||
const tone = TONE_CLASSES[statusTone(torrent)];
|
||||
// Verifying reports its own progress in recheckProgress; percentDone stays where it was, so a recheck of
|
||||
// a complete torrent would otherwise render a full bar while the daemon is 3% into the hash check.
|
||||
const isChecking = torrent.recheckProgress != null && torrent.recheckProgress > 0 && torrent.percentDone < 1;
|
||||
const fraction = isChecking ? torrent.recheckProgress! : torrent.percentDone;
|
||||
|
||||
return (
|
||||
<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 ${tone.dot}`} style={{ width: `${Math.min(100, fraction * 100)}%` }} />
|
||||
</div>
|
||||
<span className="w-11 shrink-0 text-right tabular-nums text-muted-foreground">{formatPercent(fraction)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ALL_COLUMNS: ColumnDef[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
width: 340,
|
||||
sortKey: 'name',
|
||||
render: (t) => (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{t.isPrivate && <Lock className="h-3 w-3 shrink-0 text-amber-500" aria-label="Private torrent" />}
|
||||
<span className="truncate" title={t.name}>
|
||||
{t.name}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
width: 130,
|
||||
sortKey: 'status',
|
||||
render: (t) => {
|
||||
const tone = TONE_CLASSES[statusTone(t)];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 ${tone.text}`} title={t.errorString || undefined}>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${tone.dot}`} />
|
||||
<span className="truncate">{statusLabel(t)}</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'percentDone',
|
||||
label: 'Progress',
|
||||
width: 150,
|
||||
sortKey: 'percentDone',
|
||||
render: (t) => <ProgressCell torrent={t} />,
|
||||
},
|
||||
{
|
||||
key: 'totalSize',
|
||||
label: 'Size',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
sortKey: 'totalSize',
|
||||
render: (t) => formatSize(t.totalSize),
|
||||
},
|
||||
{
|
||||
key: 'sizeWhenDone',
|
||||
label: 'Size when done',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: (t) => formatSize(t.sizeWhenDone),
|
||||
},
|
||||
{ key: 'leftUntilDone', label: 'Remaining', width: 100, align: 'right', render: (t) => formatSize(t.leftUntilDone) },
|
||||
{ key: 'haveValid', label: 'Have', width: 90, align: 'right', render: (t) => formatSize(t.haveValid) },
|
||||
{
|
||||
key: 'downloadedEver',
|
||||
label: 'Downloaded',
|
||||
width: 105,
|
||||
align: 'right',
|
||||
sortKey: 'downloadedEver',
|
||||
render: (t) => formatSize(t.downloadedEver),
|
||||
},
|
||||
{
|
||||
key: 'uploadedEver',
|
||||
label: 'Uploaded',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
sortKey: 'uploadedEver',
|
||||
render: (t) => formatSize(t.uploadedEver),
|
||||
},
|
||||
{
|
||||
key: 'rateDownload',
|
||||
label: 'Down',
|
||||
width: 95,
|
||||
align: 'right',
|
||||
sortKey: 'rateDownload',
|
||||
render: (t) => <span className="text-blue-500">{formatSpeed(t.rateDownload)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'rateUpload',
|
||||
label: 'Up',
|
||||
width: 95,
|
||||
align: 'right',
|
||||
sortKey: 'rateUpload',
|
||||
render: (t) => <span className="text-emerald-500">{formatSpeed(t.rateUpload)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'eta',
|
||||
label: 'ETA',
|
||||
width: 85,
|
||||
align: 'right',
|
||||
sortKey: 'eta',
|
||||
// ETA is meaningless once a torrent is done — Transmission keeps returning the sentinel and the column
|
||||
// would read "∞" down the whole seeding half of the list.
|
||||
render: (t) => (t.percentDone >= 1 ? '' : formatEta(t.eta)),
|
||||
},
|
||||
{
|
||||
key: 'uploadRatio',
|
||||
label: 'Ratio',
|
||||
width: 75,
|
||||
align: 'right',
|
||||
sortKey: 'uploadRatio',
|
||||
render: (t) => formatRatio(t.uploadRatio),
|
||||
},
|
||||
{
|
||||
key: 'peersSendingToUs',
|
||||
label: 'Seeds',
|
||||
width: 70,
|
||||
align: 'right',
|
||||
sortKey: 'peersSendingToUs',
|
||||
render: (t) => positive(t.peersSendingToUs),
|
||||
},
|
||||
{
|
||||
key: 'peersGettingFromUs',
|
||||
label: 'Peers',
|
||||
width: 70,
|
||||
align: 'right',
|
||||
sortKey: 'peersGettingFromUs',
|
||||
render: (t) => positive(t.peersGettingFromUs),
|
||||
},
|
||||
{ key: 'addedDate', label: 'Added', width: 145, sortKey: 'addedDate', render: (t) => formatDate(t.addedDate) },
|
||||
{ key: 'doneDate', label: 'Completed', width: 145, sortKey: 'doneDate', render: (t) => formatDate(t.doneDate) },
|
||||
{
|
||||
key: 'activityDate',
|
||||
label: 'Last active',
|
||||
width: 145,
|
||||
sortKey: 'activityDate',
|
||||
render: (t) => formatDate(t.activityDate),
|
||||
},
|
||||
{
|
||||
key: 'secondsSeeding',
|
||||
label: 'Seeding time',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
sortKey: 'secondsSeeding',
|
||||
render: (t) => formatDuration(t.secondsSeeding),
|
||||
},
|
||||
{
|
||||
key: 'downloadDir',
|
||||
label: 'Location',
|
||||
width: 220,
|
||||
sortKey: 'downloadDir',
|
||||
render: (t) => (
|
||||
<span className="truncate" title={t.downloadDir}>
|
||||
{t.downloadDir}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'officerTrackerHost',
|
||||
label: 'Tracker',
|
||||
width: 150,
|
||||
sortKey: 'officerTrackerHost',
|
||||
render: (t) => t.officerTrackerHost || '—',
|
||||
},
|
||||
{
|
||||
key: 'officerTrackerStatus',
|
||||
label: 'Tracker status',
|
||||
width: 190,
|
||||
render: (t) => (
|
||||
<span className={t.officerTrackerErrors > 0 ? 'text-amber-500' : undefined} title={t.officerTrackerStatus}>
|
||||
<span className="truncate">{t.officerTrackerStatus || '—'}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bandwidthPriority',
|
||||
label: 'Priority',
|
||||
width: 85,
|
||||
render: (t) => PRIORITY_LABELS[t.bandwidthPriority] ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'queuePosition',
|
||||
label: 'Queue',
|
||||
width: 70,
|
||||
align: 'right',
|
||||
sortKey: 'queuePosition',
|
||||
render: (t) => num(t.queuePosition),
|
||||
},
|
||||
{
|
||||
key: 'labels',
|
||||
label: 'Labels',
|
||||
width: 160,
|
||||
render: (t) =>
|
||||
t.labels.length ? (
|
||||
<span className="flex gap-1 overflow-hidden">
|
||||
{t.labels.map((l) => (
|
||||
<span key={l} className="shrink-0 rounded bg-muted px-1.5 py-px text-[10px] leading-4">
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
''
|
||||
),
|
||||
},
|
||||
{ key: 'isPrivate', label: 'Private', width: 70, align: 'center', render: (t) => (t.isPrivate ? 'Yes' : 'No') },
|
||||
{ key: 'group', label: 'Group', width: 100, render: (t) => t.group || '' },
|
||||
{ key: 'file-count', label: 'Files', width: 70, align: 'right', render: (t) => num(t['file-count']) },
|
||||
{ key: 'pieceCount', label: 'Pieces', width: 80, align: 'right', render: (t) => num(t.pieceCount) },
|
||||
{ key: 'id', label: 'ID', width: 60, align: 'right', sortKey: 'id', render: (t) => String(t.id) },
|
||||
];
|
||||
|
||||
export const COLUMN_BY_KEY = new Map(ALL_COLUMNS.map((c) => [c.key, c]));
|
||||
|
||||
/** What a fresh install shows. Everything else is one right-click on the header away. */
|
||||
export const DEFAULT_VISIBLE_COLUMNS: ColumnKey[] = [
|
||||
'name',
|
||||
'status',
|
||||
'percentDone',
|
||||
'totalSize',
|
||||
'rateDownload',
|
||||
'rateUpload',
|
||||
'eta',
|
||||
'uploadRatio',
|
||||
'peersSendingToUs',
|
||||
'peersGettingFromUs',
|
||||
'addedDate',
|
||||
];
|
||||
|
||||
/** The name column is the only thing identifying a row, so it is not hideable. */
|
||||
export const PINNED_COLUMN: ColumnKey = 'name';
|
||||
@@ -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-blue-500" 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
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';
|
||||
|
||||
// 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={() => {
|
||||
// navigator.clipboard needs a secure context; Officer is always behind HTTPS, but a failure here
|
||||
// should be silent rather than an unhandled rejection in the console.
|
||||
void navigator.clipboard?.writeText(value).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
}}
|
||||
aria-label="Copy"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -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-emerald-500" 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-blue-500">{formatSpeed(peer.rateToClient)}</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-emerald-500">{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>
|
||||
);
|
||||
};
|
||||
@@ -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-emerald-500" />
|
||||
) : (
|
||||
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
)}
|
||||
<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-amber-500'}
|
||||
/>
|
||||
<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-amber-500'}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
@@ -0,0 +1,262 @@
|
||||
import type { ChangeEvent, DragEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { FileUp, Loader2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { PRIORITY } from '../shared';
|
||||
import { useSession, useTorrentMutations } from '../useTransmissionData';
|
||||
|
||||
// Add by magnet/URL or by .torrent file, one dialog.
|
||||
//
|
||||
// Transmission's torrent-add takes either `filename` (a magnet or an http URL it fetches itself) or
|
||||
// `metainfo` (base64 of the .torrent bytes). It cannot take both, so the two inputs are exclusive here:
|
||||
// picking a file clears the text box and vice versa, rather than letting you fill in both and guess.
|
||||
|
||||
type AddTorrentDialogProps = { open: boolean; onOpenChange: (open: boolean) => void };
|
||||
|
||||
type PickedFile = { name: string; metainfo: string };
|
||||
|
||||
/** FileReader gives back a `data:...;base64,XXX` URL; torrent-add wants only the payload. */
|
||||
async function readAsBase64(file: File): Promise<string> {
|
||||
const buffer = await file.arrayBuffer();
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
// Chunked because String.fromCharCode(...bytes) blows the argument limit on a torrent with many pieces.
|
||||
for (let i = 0; i < bytes.length; i += 8192) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export const AddTorrentDialog = ({ open, onOpenChange }: AddTorrentDialogProps) => {
|
||||
const { session } = useSession();
|
||||
const { add } = useTorrentMutations();
|
||||
|
||||
const [urls, setUrls] = useState('');
|
||||
const [files, setFiles] = useState<PickedFile[]>([]);
|
||||
const [downloadDir, setDownloadDir] = useState('');
|
||||
const [labels, setLabels] = useState('');
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [priority, setPriority] = useState('0');
|
||||
const [sequential, setSequential] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const defaultDir = (session?.['download-dir'] as string) ?? '';
|
||||
|
||||
const reset = () => {
|
||||
setUrls('');
|
||||
setFiles([]);
|
||||
setDownloadDir('');
|
||||
setLabels('');
|
||||
setPaused(false);
|
||||
setPriority('0');
|
||||
setSequential(false);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const takeFiles = async (list: FileList | null) => {
|
||||
if (!list?.length) return;
|
||||
const picked = await Promise.all([...list].map(async (f) => ({ name: f.name, metainfo: await readAsBase64(f) })));
|
||||
setFiles((prev) => [...prev, ...picked]);
|
||||
setUrls('');
|
||||
};
|
||||
|
||||
const onDrop = (ev: DragEvent) => {
|
||||
ev.preventDefault();
|
||||
setDragging(false);
|
||||
void takeFiles(ev.dataTransfer.files);
|
||||
};
|
||||
|
||||
const onPick = (ev: ChangeEvent<HTMLInputElement>) => {
|
||||
void takeFiles(ev.target.files);
|
||||
// Clear the input so picking the same file twice still fires a change event.
|
||||
ev.target.value = '';
|
||||
};
|
||||
|
||||
const urlList = urls
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const canSubmit = (files.length > 0 || urlList.length > 0) && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
const common = {
|
||||
downloadDir: downloadDir.trim() || undefined,
|
||||
labels: labels
|
||||
.split(',')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean),
|
||||
paused,
|
||||
bandwidthPriority: Number(priority),
|
||||
sequentialDownload: sequential,
|
||||
};
|
||||
|
||||
setBusy(true);
|
||||
let added = 0;
|
||||
let duplicate = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Sequential rather than Promise.all: torrent-add is not idempotent and the daemon serialises it
|
||||
// anyway, and a partial failure should report which of a paste of twenty magnets got in.
|
||||
for (const file of files) {
|
||||
const result = await add.mutateAsync({ ...common, metainfo: file.metainfo }).catch(() => null);
|
||||
if (!result) failed += 1;
|
||||
else if (result.status === 'duplicate') duplicate += 1;
|
||||
else added += 1;
|
||||
}
|
||||
for (const url of urlList) {
|
||||
const result = await add.mutateAsync({ ...common, filename: url }).catch(() => null);
|
||||
if (!result) failed += 1;
|
||||
else if (result.status === 'duplicate') duplicate += 1;
|
||||
else added += 1;
|
||||
}
|
||||
setBusy(false);
|
||||
|
||||
const parts = [added && `${added} added`, duplicate && `${duplicate} already present`, failed && `${failed} failed`]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
if (added || duplicate) toast.success(parts);
|
||||
else if (failed) toast.error(parts);
|
||||
|
||||
if (!failed) close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add torrents</DialogTitle>
|
||||
<DialogDescription>Paste magnet links or drop .torrent files.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tr-add-urls">Magnet links or URLs</Label>
|
||||
<Textarea
|
||||
id="tr-add-urls"
|
||||
rows={3}
|
||||
value={urls}
|
||||
disabled={files.length > 0}
|
||||
placeholder={'magnet:?xt=urn:btih:…\nhttps://example.org/file.torrent'}
|
||||
onChange={(ev) => setUrls(ev.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">One per line.</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDragOver={(ev) => {
|
||||
ev.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
className={`rounded-lg border border-dashed p-4 text-center transition-colors ${
|
||||
dragging ? 'border-primary bg-primary/5' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<FileUp className="mx-auto h-5 w-5 text-muted-foreground" />
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Drop .torrent files here, or{' '}
|
||||
<label className="cursor-pointer text-primary underline">
|
||||
browse
|
||||
<input type="file" accept=".torrent" multiple className="hidden" onChange={onPick} />
|
||||
</label>
|
||||
</p>
|
||||
{files.length > 0 && (
|
||||
<ul className="mt-2 space-y-1 text-left">
|
||||
{files.map((f, i) => (
|
||||
<li key={`${f.name}-${i}`} className="flex items-center gap-2 rounded bg-muted px-2 py-1 text-xs">
|
||||
<span className="min-w-0 flex-1 truncate">{f.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Remove ${f.name}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label htmlFor="tr-add-dir">Download to</Label>
|
||||
<Input
|
||||
id="tr-add-dir"
|
||||
value={downloadDir}
|
||||
placeholder={defaultDir || 'server default'}
|
||||
onChange={(ev) => setDownloadDir(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tr-add-labels">Labels</Label>
|
||||
<Input
|
||||
id="tr-add-labels"
|
||||
value={labels}
|
||||
placeholder="comma, separated"
|
||||
onChange={(ev) => setLabels(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tr-add-priority">Priority</Label>
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger id="tr-add-priority">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={String(PRIORITY.High)}>High</SelectItem>
|
||||
<SelectItem value={String(PRIORITY.Normal)}>Normal</SelectItem>
|
||||
<SelectItem value={String(PRIORITY.Low)}>Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={paused} onCheckedChange={(v) => setPaused(v === true)} />
|
||||
Start paused
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={sequential} onCheckedChange={(v) => setSequential(v === true)} />
|
||||
Sequential download
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={close} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!canSubmit}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Add {files.length + urlList.length || ''}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Binary file not shown.
@@ -0,0 +1,127 @@
|
||||
import type { Torrent } from '../shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTorrentMutations } from '../useTransmissionData';
|
||||
|
||||
// torrent-set-location does two unrelated things depending on one boolean, so the dialog asks in words
|
||||
// rather than showing a checkbox called "move".
|
||||
//
|
||||
// move: true — physically relocate the data, then point the torrent at the new place
|
||||
// move: false — the data is ALREADY there; just re-point the torrent (this is how you recover after
|
||||
// moving files by hand, and it is also how you lose a torrent if you pick it by mistake)
|
||||
|
||||
type MoveTorrentDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
targets: Torrent[];
|
||||
/** Every distinct download dir currently in use, offered as one-click destinations. */
|
||||
knownDirs: string[];
|
||||
};
|
||||
|
||||
export const MoveTorrentDialog = ({ open, onOpenChange, targets, knownDirs }: MoveTorrentDialogProps) => {
|
||||
const { setLocation } = useTorrentMutations();
|
||||
const [location, setLocationValue] = useState('');
|
||||
const [move, setMove] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Seed with the current location when they all share one — the common case is a small edit to it.
|
||||
const dirs = new Set(targets.map((t) => t.downloadDir));
|
||||
setLocationValue(dirs.size === 1 ? [...dirs][0]! : '');
|
||||
setMove(true);
|
||||
}, [open, targets]);
|
||||
|
||||
const submit = async () => {
|
||||
await setLocation.mutateAsync({ ids: targets.map((t) => t.id), location: location.trim(), move });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set location</DialogTitle>
|
||||
<DialogDescription>
|
||||
{targets.length === 1 ? targets[0]!.name : `${targets.length} torrents`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tr-move-path">Destination</Label>
|
||||
<Input
|
||||
id="tr-move-path"
|
||||
value={location}
|
||||
onChange={(ev) => setLocationValue(ev.target.value)}
|
||||
placeholder="/downloads/complete"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{knownDirs.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{knownDirs.map((dir) => (
|
||||
<button
|
||||
key={dir}
|
||||
type="button"
|
||||
onClick={() => setLocationValue(dir)}
|
||||
title={dir}
|
||||
className="max-w-full truncate rounded bg-muted px-2 py-1 text-xs hover:bg-muted/70"
|
||||
>
|
||||
{dir}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
|
||||
<input type="radio" name="tr-move-mode" className="mt-1" checked={move} onChange={() => setMove(true)} />
|
||||
<span>
|
||||
<span className="font-medium">Move the data there</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
Transmission copies the files to the new location and removes the originals.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="tr-move-mode"
|
||||
className="mt-1"
|
||||
checked={!move}
|
||||
onChange={() => setMove(false)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">The data is already there</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
Nothing is copied. Use this after moving files yourself — if the files are not there, the torrent will
|
||||
re-download.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!location.trim() || setLocation.isPending}>
|
||||
Apply
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Torrent } from '../shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { formatSize } from '../format';
|
||||
import { useTorrentMutations } from '../useTransmissionData';
|
||||
|
||||
// Removing a torrent and deleting its data are one RPC apart and a very long way apart in consequence, so
|
||||
// they are one dialog with an explicit opt-in rather than two menu items you can mis-click between.
|
||||
|
||||
type RemoveTorrentDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
targets: Torrent[];
|
||||
onRemoved?: () => void;
|
||||
};
|
||||
|
||||
export const RemoveTorrentDialog = ({ open, onOpenChange, targets, onRemoved }: RemoveTorrentDialogProps) => {
|
||||
const { remove } = useTorrentMutations();
|
||||
const [deleteData, setDeleteData] = useState(false);
|
||||
|
||||
// Re-arm on every open: "also delete the files" must never be inherited from the last time.
|
||||
useEffect(() => {
|
||||
if (open) setDeleteData(false);
|
||||
}, [open]);
|
||||
|
||||
const bytes = targets.reduce((sum, t) => sum + (t.totalSize ?? 0), 0);
|
||||
const one = targets.length === 1 ? targets[0] : null;
|
||||
|
||||
const confirm = async () => {
|
||||
await remove.mutateAsync({ ids: targets.map((t) => t.id), deleteLocalData: deleteData });
|
||||
onOpenChange(false);
|
||||
onRemoved?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove {one ? 'torrent' : `${targets.length} torrents`}?</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
{one ? one.name : `${targets.length} torrents totalling ${formatSize(bytes)}.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<label className="flex items-start gap-2.5 rounded-lg border border-border p-3 text-sm">
|
||||
<Checkbox className="mt-0.5" checked={deleteData} onCheckedChange={(v) => setDeleteData(v === true)} />
|
||||
<span>
|
||||
<span className="font-medium">Also delete the downloaded files</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
{deleteData
|
||||
? `${formatSize(bytes)} will be deleted from disk. This cannot be undone.`
|
||||
: 'The files stay on disk; only the torrent is removed.'}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{deleteData && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<TriangleAlert className="h-4 w-4 shrink-0" />
|
||||
Transmission deletes the data immediately — there is no trash.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirm} disabled={remove.isPending}>
|
||||
{deleteData ? 'Remove and delete' : 'Remove'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Torrent } from '../shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTorrentMutations } from '../useTransmissionData';
|
||||
|
||||
// torrent-rename-path renames a path *inside* the torrent, addressed by its full path relative to the
|
||||
// torrent root. Renaming the root itself means passing the torrent's own name as the path — which is why
|
||||
// this dialog takes a `path` as well as the new name, and why it is single-target only.
|
||||
|
||||
type RenameDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
torrent: Torrent | null;
|
||||
/** The path being renamed; the torrent's own name when renaming the root. */
|
||||
path: string;
|
||||
};
|
||||
|
||||
export const RenameDialog = ({ open, onOpenChange, torrent, path }: RenameDialogProps) => {
|
||||
const { rename } = useTorrentMutations();
|
||||
const [name, setName] = useState('');
|
||||
|
||||
// Only the last segment is editable — the rest of the path is where the file lives, not what it is called.
|
||||
const segments = path.split('/');
|
||||
const parent = segments.slice(0, -1).join('/');
|
||||
const current = segments[segments.length - 1] ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setName(current);
|
||||
}, [open, current]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!torrent) return;
|
||||
await rename.mutateAsync({ id: torrent.id, path, name: name.trim() });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename</DialogTitle>
|
||||
<DialogDescription className="break-all">{parent ? `${parent}/` : path}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tr-rename">New name</Label>
|
||||
<Input
|
||||
id="tr-rename"
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
onKeyDown={(ev) => ev.key === 'Enter' && name.trim() && void submit()}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This renames the file or folder on disk, not just the display name.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!name.trim() || name.trim() === current || rename.isPending}>
|
||||
Rename
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { Torrent } from '../shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { LIMIT_MODE, PRIORITY } from '../shared';
|
||||
import { useTorrentMutations } from '../useTransmissionData';
|
||||
|
||||
// Per-torrent bandwidth, seeding and peer options — the reference client's "other settings" dialog.
|
||||
//
|
||||
// Every limit here is a pair (a boolean and a number) because Transmission stores them that way: turning a
|
||||
// limit off keeps the number, so unchecking and re-checking gets you back the value you had.
|
||||
|
||||
type TorrentOptionsDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; targets: Torrent[] };
|
||||
|
||||
type FormState = {
|
||||
bandwidthPriority: string;
|
||||
honorsSessionLimits: boolean;
|
||||
downloadLimited: boolean;
|
||||
downloadLimit: string;
|
||||
uploadLimited: boolean;
|
||||
uploadLimit: string;
|
||||
seedRatioMode: string;
|
||||
seedRatioLimit: string;
|
||||
seedIdleMode: string;
|
||||
seedIdleLimit: string;
|
||||
peerLimit: string;
|
||||
sequential: boolean;
|
||||
};
|
||||
|
||||
const fromTorrent = (t: Torrent): FormState => ({
|
||||
bandwidthPriority: String(t.bandwidthPriority ?? 0),
|
||||
honorsSessionLimits: t.honorsSessionLimits ?? true,
|
||||
downloadLimited: t.downloadLimited ?? false,
|
||||
downloadLimit: String(t.downloadLimit ?? 0),
|
||||
uploadLimited: t.uploadLimited ?? false,
|
||||
uploadLimit: String(t.uploadLimit ?? 0),
|
||||
seedRatioMode: String(t.seedRatioMode ?? 0),
|
||||
seedRatioLimit: String(t.seedRatioLimit ?? 2),
|
||||
seedIdleMode: String(t.seedIdleMode ?? 0),
|
||||
seedIdleLimit: String(t.seedIdleLimit ?? 30),
|
||||
peerLimit: String(t['peer-limit'] ?? 50),
|
||||
sequential: t.sequential_download ?? false,
|
||||
});
|
||||
|
||||
export const TorrentOptionsDialog = ({ open, onOpenChange, targets }: TorrentOptionsDialogProps) => {
|
||||
const { set } = useTorrentMutations();
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Seeded from the first target. With a mixed selection the others' values are simply overwritten —
|
||||
// which is what "apply these settings to all of them" means, and the header says so.
|
||||
if (open && targets[0]) setForm(fromTorrent(targets[0]));
|
||||
}, [open, targets]);
|
||||
|
||||
if (!form) return null;
|
||||
|
||||
const patch = (next: Partial<FormState>) => setForm((prev) => (prev ? { ...prev, ...next } : prev));
|
||||
|
||||
const submit = async () => {
|
||||
await set.mutateAsync({
|
||||
ids: targets.map((t) => t.id),
|
||||
bandwidthPriority: Number(form.bandwidthPriority),
|
||||
honorsSessionLimits: form.honorsSessionLimits,
|
||||
downloadLimited: form.downloadLimited,
|
||||
downloadLimit: Math.max(0, Number(form.downloadLimit) || 0),
|
||||
uploadLimited: form.uploadLimited,
|
||||
uploadLimit: Math.max(0, Number(form.uploadLimit) || 0),
|
||||
seedRatioMode: Number(form.seedRatioMode),
|
||||
seedRatioLimit: Math.max(0, Number(form.seedRatioLimit) || 0),
|
||||
seedIdleMode: Number(form.seedIdleMode),
|
||||
seedIdleLimit: Math.max(0, Number(form.seedIdleLimit) || 0),
|
||||
'peer-limit': Math.max(1, Number(form.peerLimit) || 1),
|
||||
sequential_download: form.sequential,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Torrent options</DialogTitle>
|
||||
<DialogDescription>
|
||||
{targets.length === 1
|
||||
? targets[0]!.name
|
||||
: `Applying to ${targets.length} torrents — all of them will take these values.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bandwidth</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tr-opt-priority">Priority</Label>
|
||||
<Select value={form.bandwidthPriority} onValueChange={(v) => patch({ bandwidthPriority: v })}>
|
||||
<SelectTrigger id="tr-opt-priority">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={String(PRIORITY.High)}>High</SelectItem>
|
||||
<SelectItem value={String(PRIORITY.Normal)}>Normal</SelectItem>
|
||||
<SelectItem value={String(PRIORITY.Low)}>Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tr-opt-peers">Maximum peers</Label>
|
||||
<Input
|
||||
id="tr-opt-peers"
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.peerLimit}
|
||||
onChange={(ev) => patch({ peerLimit: ev.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LimitRow
|
||||
id="tr-opt-down"
|
||||
label="Limit download speed"
|
||||
enabled={form.downloadLimited}
|
||||
value={form.downloadLimit}
|
||||
onToggle={(v) => patch({ downloadLimited: v })}
|
||||
onChange={(v) => patch({ downloadLimit: v })}
|
||||
/>
|
||||
<LimitRow
|
||||
id="tr-opt-up"
|
||||
label="Limit upload speed"
|
||||
enabled={form.uploadLimited}
|
||||
value={form.uploadLimit}
|
||||
onToggle={(v) => patch({ uploadLimited: v })}
|
||||
onChange={(v) => patch({ uploadLimit: v })}
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={form.honorsSessionLimits}
|
||||
onCheckedChange={(v) => patch({ honorsSessionLimits: v === true })}
|
||||
/>
|
||||
Honour the global speed limits
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={form.sequential} onCheckedChange={(v) => patch({ sequential: v === true })} />
|
||||
Download pieces in order
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Stop seeding when</h3>
|
||||
<div className="grid grid-cols-[1fr_120px] items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tr-opt-ratio-mode">Ratio reaches</Label>
|
||||
<Select value={form.seedRatioMode} onValueChange={(v) => patch({ seedRatioMode: v })}>
|
||||
<SelectTrigger id="tr-opt-ratio-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={String(LIMIT_MODE.Global)}>Use the global setting</SelectItem>
|
||||
<SelectItem value={String(LIMIT_MODE.Single)}>Use this value</SelectItem>
|
||||
<SelectItem value={String(LIMIT_MODE.Unlimited)}>Seed forever</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min={0}
|
||||
disabled={form.seedRatioMode !== String(LIMIT_MODE.Single)}
|
||||
value={form.seedRatioLimit}
|
||||
onChange={(ev) => patch({ seedRatioLimit: ev.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_120px] items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tr-opt-idle-mode">Idle for (minutes)</Label>
|
||||
<Select value={form.seedIdleMode} onValueChange={(v) => patch({ seedIdleMode: v })}>
|
||||
<SelectTrigger id="tr-opt-idle-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={String(LIMIT_MODE.Global)}>Use the global setting</SelectItem>
|
||||
<SelectItem value={String(LIMIT_MODE.Single)}>Use this value</SelectItem>
|
||||
<SelectItem value={String(LIMIT_MODE.Unlimited)}>Never stop</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
disabled={form.seedIdleMode !== String(LIMIT_MODE.Single)}
|
||||
value={form.seedIdleLimit}
|
||||
onChange={(ev) => patch({ seedIdleLimit: ev.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={set.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
type LimitRowProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
value: string;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const LimitRow = ({ id, label, enabled, value, onToggle, onChange }: LimitRowProps) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex flex-1 items-center gap-2 text-sm">
|
||||
<Checkbox checked={enabled} onCheckedChange={(v) => onToggle(v === true)} />
|
||||
{label}
|
||||
</label>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
min={0}
|
||||
disabled={!enabled}
|
||||
value={value}
|
||||
onChange={(ev) => onChange(ev.target.value)}
|
||||
className="w-28"
|
||||
/>
|
||||
<span className="w-10 text-xs text-muted-foreground">kB/s</span>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Torrent } from '../shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTorrentMutations } from '../useTransmissionData';
|
||||
|
||||
// Transmission 4 replaced the old trackerAdd/trackerRemove/trackerReplace triple with `trackerList`: the
|
||||
// whole announce list as text, one URL per line, a blank line between tiers. Editing text is also the only
|
||||
// honest way to express tiers, which a list of rows with buttons cannot.
|
||||
|
||||
type TrackersDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; torrent: Torrent | null };
|
||||
|
||||
/** Rebuild the tier-separated text from trackerStats when the daemon didn't send trackerList. */
|
||||
function fromStats(torrent: Torrent): string {
|
||||
const tiers = new Map<number, string[]>();
|
||||
for (const t of torrent.trackerStats ?? []) {
|
||||
const list = tiers.get(t.tier) ?? [];
|
||||
list.push(t.announce);
|
||||
tiers.set(t.tier, list);
|
||||
}
|
||||
return [...tiers.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, urls]) => urls.join('\n'))
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export const TrackersDialog = ({ open, onOpenChange, torrent }: TrackersDialogProps) => {
|
||||
const { set } = useTorrentMutations();
|
||||
const [text, setText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !torrent) return;
|
||||
setText(torrent.trackerList ?? fromStats(torrent));
|
||||
}, [open, torrent]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!torrent) return;
|
||||
// Trailing whitespace on a line makes Transmission reject the whole list, and pasting from a website
|
||||
// reliably brings some along.
|
||||
const normalised = text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.join('\n');
|
||||
await set.mutateAsync({ ids: [torrent.id], trackerList: normalised });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Trackers</DialogTitle>
|
||||
<DialogDescription className="break-words">{torrent?.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tr-trackers">Announce URLs</Label>
|
||||
<Textarea
|
||||
id="tr-trackers"
|
||||
rows={10}
|
||||
value={text}
|
||||
onChange={(ev) => setText(ev.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
One URL per line. Leave a blank line between tiers — Transmission only falls through to the next tier when
|
||||
every tracker in the one above it fails.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{torrent?.isPrivate && (
|
||||
<p className="rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-500">
|
||||
This is a private torrent. Adding trackers it wasn't issued with will not help and may get you banned from
|
||||
the tracker it came from.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={set.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { Torrent } from './shared';
|
||||
import { STATUS } from './shared';
|
||||
|
||||
// Display formatting for the Transmission panels. One implementation each, because a torrent list shows the
|
||||
// same quantity in a column, a tooltip and a detail row, and three near-identical formatters drift.
|
||||
|
||||
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
||||
/** Binary (1024-based) sizes, matching what Transmission's own clients show. */
|
||||
export function formatSize(bytes: number | undefined | null): string {
|
||||
if (bytes == null || !Number.isFinite(bytes)) return '—';
|
||||
if (bytes <= 0) return '0 B';
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), SIZE_UNITS.length - 1);
|
||||
const value = bytes / 1024 ** i;
|
||||
return `${value.toFixed(i === 0 ? 0 : value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${SIZE_UNITS[i]}`;
|
||||
}
|
||||
|
||||
/** Zero renders blank, not "0 B/s" — a list where every idle row shouts zero is unreadable. */
|
||||
export function formatSpeed(bytesPerSecond: number | undefined | null): string {
|
||||
if (!bytesPerSecond || bytesPerSecond <= 0) return '';
|
||||
return `${formatSize(bytesPerSecond)}/s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transmission's ETA sentinels: -1 means "not available", -2 means "unknown". Both are ordinary states for a
|
||||
* stopped or seeding torrent, so neither is an error worth surfacing.
|
||||
*/
|
||||
export function formatEta(seconds: number | undefined | null): string {
|
||||
if (seconds == null || seconds < 0) return '∞';
|
||||
return formatDuration(seconds);
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number | undefined | null): string {
|
||||
if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return '—';
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`;
|
||||
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ratio, with the two conventions every torrent client shares: nothing uploaded yet reads as a dash, and
|
||||
* anything uploaded against a zero download is infinite rather than a division blow-up.
|
||||
*/
|
||||
export function formatRatio(ratio: number | undefined | null): string {
|
||||
if (ratio == null) return '—';
|
||||
if (ratio < 0) return '∞';
|
||||
return ratio.toFixed(2);
|
||||
}
|
||||
|
||||
/** Transmission timestamps are unix seconds, and 0 means "never" rather than 1970. */
|
||||
export function formatDate(unixSeconds: number | undefined | null): string {
|
||||
if (!unixSeconds || unixSeconds <= 0) return '—';
|
||||
const d = new Date(unixSeconds * 1000);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function formatPercent(fraction: number | undefined | null): string {
|
||||
if (fraction == null) return '—';
|
||||
return `${(fraction * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
// ── Status ────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type StatusTone = 'stopped' | 'downloading' | 'seeding' | 'checking' | 'queued' | 'error';
|
||||
|
||||
export function statusTone(torrent: Torrent): StatusTone {
|
||||
if (torrent.error !== 0) return 'error';
|
||||
switch (torrent.status) {
|
||||
case STATUS.Download:
|
||||
return 'downloading';
|
||||
case STATUS.Seed:
|
||||
return 'seeding';
|
||||
case STATUS.Check:
|
||||
case STATUS.CheckWait:
|
||||
return 'checking';
|
||||
case STATUS.DownloadWait:
|
||||
case STATUS.SeedWait:
|
||||
return 'queued';
|
||||
default:
|
||||
return 'stopped';
|
||||
}
|
||||
}
|
||||
|
||||
export function statusLabel(torrent: Torrent): string {
|
||||
if (torrent.error !== 0) return 'Error';
|
||||
switch (torrent.status) {
|
||||
case STATUS.Stopped:
|
||||
// A stopped torrent that never finished is paused; one that did is simply done and not seeding.
|
||||
return torrent.percentDone >= 1 ? 'Finished' : 'Paused';
|
||||
case STATUS.CheckWait:
|
||||
return 'Queued to verify';
|
||||
case STATUS.Check:
|
||||
return 'Verifying';
|
||||
case STATUS.DownloadWait:
|
||||
return 'Queued to download';
|
||||
case STATUS.Download:
|
||||
// Transmission reports "downloading" while it is still fetching the metadata of a magnet link, which
|
||||
// is a materially different state — there is nothing to download yet.
|
||||
return torrent.metadataPercentComplete < 1 ? 'Fetching metadata' : 'Downloading';
|
||||
case STATUS.SeedWait:
|
||||
return 'Queued to seed';
|
||||
case STATUS.Seed:
|
||||
return 'Seeding';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/** Tailwind classes per tone, kept together so the list, the pill and the detail header cannot disagree. */
|
||||
export const TONE_CLASSES: Record<StatusTone, { text: string; bg: string; dot: string }> = {
|
||||
stopped: { text: 'text-muted-foreground', bg: 'bg-muted', dot: 'bg-muted-foreground' },
|
||||
downloading: { text: 'text-blue-500', bg: 'bg-blue-500/10', dot: 'bg-blue-500' },
|
||||
seeding: { text: 'text-emerald-500', bg: 'bg-emerald-500/10', dot: 'bg-emerald-500' },
|
||||
checking: { text: 'text-cyan-500', bg: 'bg-cyan-500/10', dot: 'bg-cyan-500' },
|
||||
queued: { text: 'text-amber-500', bg: 'bg-amber-500/10', dot: 'bg-amber-500' },
|
||||
error: { text: 'text-destructive', bg: 'bg-destructive/10', dot: 'bg-destructive' },
|
||||
};
|
||||
|
||||
export const PRIORITY_LABELS: Record<number, string> = { [-1]: 'Low', 0: 'Normal', 1: 'High' };
|
||||
|
||||
/** Peer flag characters, spelled out for the peers table tooltip (rpc-spec.md §3.2). */
|
||||
export const PEER_FLAGS: Record<string, string> = {
|
||||
O: 'Optimistic unchoke',
|
||||
D: 'Downloading from peer',
|
||||
d: 'We would download but peer is choking us',
|
||||
U: 'Uploading to peer',
|
||||
u: 'Peer would download but we are choking',
|
||||
K: 'Peer unchoked us but we are not interested',
|
||||
'?': 'We unchoked peer but it is not interested',
|
||||
E: 'Encrypted connection',
|
||||
H: 'Peer discovered through DHT',
|
||||
X: 'Peer discovered through PEX',
|
||||
I: 'Peer is incoming',
|
||||
T: 'Peer is over uTP',
|
||||
};
|
||||
|
||||
export function describePeerFlags(flagStr: string): string {
|
||||
return (
|
||||
flagStr
|
||||
.split('')
|
||||
.map((c) => PEER_FLAGS[c])
|
||||
.filter(Boolean)
|
||||
.join('\n') || flagStr
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { PanelLeft, LayoutGrid } from 'lucide-react';
|
||||
import { TransmissionNav } from './TransmissionNav';
|
||||
import { TransmissionView } from './TransmissionView';
|
||||
import { TransmissionViewHeader } from './TransmissionViewHeader';
|
||||
|
||||
export { TransmissionNav, TransmissionView };
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'transmission-nav',
|
||||
name: 'Transmission',
|
||||
icon: PanelLeft,
|
||||
component: TransmissionNav,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{
|
||||
key: 'transmission-view',
|
||||
name: 'Transmission',
|
||||
icon: LayoutGrid,
|
||||
component: TransmissionView,
|
||||
header: TransmissionViewHeader,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,192 @@
|
||||
// Shared types/constants for the /transmission workspace panels. The wire shapes mirror what the
|
||||
// officer-transmission sidecar returns under /api/transmission/_officer/* — which in turn keeps
|
||||
// Transmission's own field names, casing warts and all (`percentDone` beside `peer-limit` and
|
||||
// `sequential_download`). Renaming them here would mean this file and Transmission's rpc-spec.md disagree
|
||||
// on what a field is called, which is exactly the confusion you don't want at 2am.
|
||||
//
|
||||
// The `officer*` fields are the sidecar's own additions — see src/servers/sidecar/transmission/rpc.ts.
|
||||
|
||||
export const TRANSMISSION_SECTIONS = [
|
||||
{ id: 'torrents', label: 'Torrents' },
|
||||
{ id: 'stats', label: 'Statistics' },
|
||||
{ id: 'settings', label: 'Settings' },
|
||||
] as const;
|
||||
|
||||
export type TransmissionSectionId = (typeof TRANSMISSION_SECTIONS)[number]['id'];
|
||||
|
||||
/** Where /transmission lands, and where an unrecognised section redirects to. */
|
||||
export const DEFAULT_TRANSMISSION_SECTION: TransmissionSectionId = 'torrents';
|
||||
|
||||
export const isTransmissionSection = (value: string | undefined): value is TransmissionSectionId =>
|
||||
TRANSMISSION_SECTIONS.some((s) => s.id === value);
|
||||
|
||||
/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
|
||||
export const transmissionSectionPath = (id: TransmissionSectionId) => `/transmission/${id}`;
|
||||
|
||||
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Transmission's torrent status enum (rpc-spec.md §3.3). */
|
||||
export const STATUS = {
|
||||
Stopped: 0,
|
||||
CheckWait: 1,
|
||||
Check: 2,
|
||||
DownloadWait: 3,
|
||||
Download: 4,
|
||||
SeedWait: 5,
|
||||
Seed: 6,
|
||||
} as const;
|
||||
|
||||
export type TrackerStat = {
|
||||
announce: string;
|
||||
announceState: number;
|
||||
downloadCount: number;
|
||||
hasAnnounced: boolean;
|
||||
hasScraped: boolean;
|
||||
host: string;
|
||||
id: number;
|
||||
isBackup: boolean;
|
||||
lastAnnouncePeerCount: number;
|
||||
lastAnnounceResult: string;
|
||||
lastAnnounceSucceeded: boolean;
|
||||
lastAnnounceTime: number;
|
||||
lastScrapeResult: string;
|
||||
lastScrapeSucceeded: boolean;
|
||||
lastScrapeTime: number;
|
||||
leecherCount: number;
|
||||
nextAnnounceTime: number;
|
||||
scrape: string;
|
||||
seederCount: number;
|
||||
tier: number;
|
||||
};
|
||||
|
||||
export type TorrentFile = { bytesCompleted: number; length: number; name: string };
|
||||
export type TorrentFileStat = { bytesCompleted: number; priority: number; wanted: boolean };
|
||||
|
||||
export type TorrentPeer = {
|
||||
address: string;
|
||||
clientName: string;
|
||||
flagStr: string;
|
||||
isEncrypted: boolean;
|
||||
isIncoming: boolean;
|
||||
isUTP: boolean;
|
||||
port: number;
|
||||
progress: number;
|
||||
rateToClient: number;
|
||||
rateToPeer: number;
|
||||
};
|
||||
|
||||
export type PeersFrom = {
|
||||
fromCache: number;
|
||||
fromDht: number;
|
||||
fromIncoming: number;
|
||||
fromLpd: number;
|
||||
fromLtep: number;
|
||||
fromPex: number;
|
||||
fromTracker: number;
|
||||
};
|
||||
|
||||
export type Torrent = {
|
||||
id: number;
|
||||
name: string;
|
||||
status: number;
|
||||
totalSize: number;
|
||||
sizeWhenDone: number;
|
||||
leftUntilDone: number;
|
||||
haveValid: number;
|
||||
percentDone: number;
|
||||
metadataPercentComplete: number;
|
||||
recheckProgress?: number;
|
||||
downloadedEver: number;
|
||||
uploadedEver: number;
|
||||
corruptEver?: number;
|
||||
uploadRatio: number;
|
||||
rateDownload: number;
|
||||
rateUpload: number;
|
||||
eta: number;
|
||||
peersSendingToUs: number;
|
||||
peersGettingFromUs: number;
|
||||
addedDate: number;
|
||||
doneDate: number;
|
||||
activityDate: number;
|
||||
secondsSeeding: number;
|
||||
downloadDir: string;
|
||||
bandwidthPriority: number;
|
||||
queuePosition: number;
|
||||
isPrivate: boolean;
|
||||
labels: string[];
|
||||
error: number;
|
||||
errorString: string;
|
||||
pieceCount: number;
|
||||
pieceSize: number;
|
||||
magnetLink: string;
|
||||
group: string;
|
||||
'file-count': number;
|
||||
trackerStats: TrackerStat[];
|
||||
trackerList?: string;
|
||||
honorsSessionLimits: boolean;
|
||||
downloadLimited: boolean;
|
||||
downloadLimit: number;
|
||||
uploadLimited: boolean;
|
||||
uploadLimit: number;
|
||||
'peer-limit': number;
|
||||
seedRatioMode: number;
|
||||
seedRatioLimit: number;
|
||||
seedIdleMode: number;
|
||||
seedIdleLimit: number;
|
||||
sequential_download?: boolean;
|
||||
// Detail-only — present when fetched through useTorrentDetail.
|
||||
hashString?: string;
|
||||
comment?: string;
|
||||
creator?: string;
|
||||
dateCreated?: number;
|
||||
maxConnectedPeers?: number;
|
||||
files?: TorrentFile[];
|
||||
fileStats?: TorrentFileStat[];
|
||||
peers?: TorrentPeer[];
|
||||
peersFrom?: PeersFrom;
|
||||
// Derived by the sidecar.
|
||||
officerTrackerHost: string;
|
||||
officerTrackerStatus: string;
|
||||
officerTrackerErrors: number;
|
||||
};
|
||||
|
||||
export type SessionSettings = Record<string, unknown> & {
|
||||
version: string;
|
||||
'rpc-version': number;
|
||||
'download-dir': string;
|
||||
};
|
||||
|
||||
export type StatsBucket = {
|
||||
downloadedBytes: number;
|
||||
uploadedBytes: number;
|
||||
filesAdded: number;
|
||||
sessionCount: number;
|
||||
secondsActive: number;
|
||||
};
|
||||
|
||||
export type SessionStats = {
|
||||
activeTorrentCount: number;
|
||||
pausedTorrentCount: number;
|
||||
torrentCount: number;
|
||||
downloadSpeed: number;
|
||||
uploadSpeed: number;
|
||||
'cumulative-stats': StatsBucket;
|
||||
'current-stats': StatsBucket;
|
||||
};
|
||||
|
||||
/** The ids the sidecar accepts at POST /_officer/torrents/action. */
|
||||
export type TorrentAction =
|
||||
| 'start'
|
||||
| 'start-now'
|
||||
| 'stop'
|
||||
| 'verify'
|
||||
| 'reannounce'
|
||||
| 'queue-top'
|
||||
| 'queue-up'
|
||||
| 'queue-down'
|
||||
| 'queue-bottom';
|
||||
|
||||
export const PRIORITY = { Low: -1, Normal: 0, High: 1 } as const;
|
||||
|
||||
/** Transmission's seedRatioMode / seedIdleMode share this vocabulary. */
|
||||
export const LIMIT_MODE = { Global: 0, Single: 1, Unlimited: 2 } as const;
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { Torrent } from './shared';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { STATUS } from './shared';
|
||||
|
||||
// Filtering and sorting for the torrent list, with every knob in the query string.
|
||||
//
|
||||
// The nav panel writes these params and the table panel reads them; they never talk to each other. That is
|
||||
// the whole reason the state lives in the URL rather than in a channel — two sibling panels, one address,
|
||||
// and a filtered view you can bookmark or send to yourself.
|
||||
|
||||
export const STATUS_FILTERS = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'active', label: 'Active' },
|
||||
{ id: 'downloading', label: 'Downloading' },
|
||||
{ id: 'seeding', label: 'Seeding' },
|
||||
{ id: 'paused', label: 'Paused' },
|
||||
{ id: 'finished', label: 'Finished' },
|
||||
{ id: 'checking', label: 'Verifying' },
|
||||
{ id: 'error', label: 'Error' },
|
||||
] as const;
|
||||
|
||||
export type StatusFilterId = (typeof STATUS_FILTERS)[number]['id'];
|
||||
|
||||
const isStatusFilter = (v: string | null): v is StatusFilterId => !!v && STATUS_FILTERS.some((f) => f.id === v);
|
||||
|
||||
export type SortKey =
|
||||
| 'name'
|
||||
| 'queuePosition'
|
||||
| 'addedDate'
|
||||
| 'doneDate'
|
||||
| 'activityDate'
|
||||
| 'percentDone'
|
||||
| 'totalSize'
|
||||
| 'rateDownload'
|
||||
| 'rateUpload'
|
||||
| 'uploadRatio'
|
||||
| 'status'
|
||||
| 'eta'
|
||||
| 'downloadedEver'
|
||||
| 'uploadedEver'
|
||||
| 'peersSendingToUs'
|
||||
| 'peersGettingFromUs'
|
||||
| 'secondsSeeding'
|
||||
| 'downloadDir'
|
||||
| 'officerTrackerHost'
|
||||
| 'id';
|
||||
|
||||
const DEFAULT_SORT: SortKey = 'queuePosition';
|
||||
|
||||
/** Param names, in one place so the nav, the table header and the screen guard cannot drift. */
|
||||
const P = {
|
||||
q: 'q',
|
||||
status: 'status',
|
||||
label: 'label',
|
||||
tracker: 'tracker',
|
||||
dir: 'dir',
|
||||
error: 'err',
|
||||
sort: 'sort',
|
||||
desc: 'desc',
|
||||
selected: 'selected',
|
||||
} as const;
|
||||
|
||||
export type TorrentFilters = {
|
||||
q: string;
|
||||
status: StatusFilterId;
|
||||
label: string | null;
|
||||
tracker: string | null;
|
||||
downloadDir: string | null;
|
||||
errorOnly: boolean;
|
||||
sort: SortKey;
|
||||
desc: boolean;
|
||||
/** Any of the narrowing filters (not sort, not selection) is engaged. */
|
||||
isFiltered: boolean;
|
||||
};
|
||||
|
||||
export function useTorrentFilters() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const rawStatus = params.get(P.status);
|
||||
const filters = useMemo<TorrentFilters>(() => {
|
||||
const q = params.get(P.q) ?? '';
|
||||
const status = isStatusFilter(rawStatus) ? rawStatus : 'all';
|
||||
const label = params.get(P.label);
|
||||
const tracker = params.get(P.tracker);
|
||||
const downloadDir = params.get(P.dir);
|
||||
const errorOnly = params.get(P.error) === '1';
|
||||
return {
|
||||
q,
|
||||
status,
|
||||
label,
|
||||
tracker,
|
||||
downloadDir,
|
||||
errorOnly,
|
||||
sort: (params.get(P.sort) as SortKey) || DEFAULT_SORT,
|
||||
desc: params.get(P.desc) === '1',
|
||||
isFiltered: !!q || status !== 'all' || !!label || !!tracker || !!downloadDir || errorOnly,
|
||||
};
|
||||
// params is a fresh URLSearchParams object on every render, so depend on the values instead of the
|
||||
// object — otherwise every render produces a new filters object and re-sorts the whole list.
|
||||
}, [
|
||||
params.get(P.q),
|
||||
rawStatus,
|
||||
params.get(P.label),
|
||||
params.get(P.tracker),
|
||||
params.get(P.dir),
|
||||
params.get(P.error),
|
||||
params.get(P.sort),
|
||||
params.get(P.desc),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Writes go through the functional form of setSearchParams so concurrent updates (typing in the search
|
||||
* box while a filter menu closes) compose instead of clobbering each other. `replace` keeps the back
|
||||
* button useful: forty keystrokes should not be forty history entries.
|
||||
*/
|
||||
const patch = useCallback(
|
||||
(next: Partial<Record<keyof typeof P, string | null>>, replace = true) => {
|
||||
setParams(
|
||||
(prev) => {
|
||||
const out = new URLSearchParams(prev);
|
||||
for (const [key, value] of Object.entries(next)) {
|
||||
const param = P[key as keyof typeof P];
|
||||
if (value === null || value === '') out.delete(param);
|
||||
else out.set(param, value);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
{ replace },
|
||||
);
|
||||
},
|
||||
[setParams],
|
||||
);
|
||||
|
||||
const setSearch = useCallback((q: string) => patch({ q: q || null }), [patch]);
|
||||
const setStatus = useCallback((s: StatusFilterId) => patch({ status: s === 'all' ? null : s }), [patch]);
|
||||
|
||||
/** Menu entries toggle: clicking the one already applied clears it. Saves a separate "clear" affordance. */
|
||||
const toggle = useCallback(
|
||||
(key: 'label' | 'tracker' | 'dir' | 'error', value: string, current: string | null) =>
|
||||
patch({ [key]: current === value ? null : value }),
|
||||
[patch],
|
||||
);
|
||||
|
||||
const clearFilters = useCallback(
|
||||
() => patch({ q: null, status: null, label: null, tracker: null, dir: null, error: null }, false),
|
||||
[patch],
|
||||
);
|
||||
|
||||
/**
|
||||
* Sorting toggles direction when you pick the column that is already sorted. New columns start ascending,
|
||||
* except the ones where "most" is the interesting end — nobody opens a torrent list to see the slowest.
|
||||
*/
|
||||
const setSort = useCallback(
|
||||
(key: SortKey, currentSort: SortKey, currentDesc: boolean) => {
|
||||
if (key === currentSort) return patch({ sort: key, desc: currentDesc ? null : '1' });
|
||||
return patch({ sort: key, desc: DESC_BY_DEFAULT.has(key) ? '1' : null });
|
||||
},
|
||||
[patch],
|
||||
);
|
||||
|
||||
const selectedId = Number(params.get(P.selected));
|
||||
const selected = Number.isFinite(selectedId) && selectedId > 0 ? selectedId : null;
|
||||
const setSelected = useCallback((id: number | null) => patch({ selected: id ? String(id) : null }), [patch]);
|
||||
|
||||
return { filters, setSearch, setStatus, toggle, clearFilters, setSort, selected, setSelected };
|
||||
}
|
||||
|
||||
const DESC_BY_DEFAULT = new Set<SortKey>([
|
||||
'addedDate',
|
||||
'doneDate',
|
||||
'activityDate',
|
||||
'rateDownload',
|
||||
'rateUpload',
|
||||
'uploadRatio',
|
||||
'totalSize',
|
||||
'downloadedEver',
|
||||
'uploadedEver',
|
||||
'peersSendingToUs',
|
||||
'peersGettingFromUs',
|
||||
'secondsSeeding',
|
||||
'percentDone',
|
||||
]);
|
||||
|
||||
// ── Predicates ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function matchesStatus(t: Torrent, filter: StatusFilterId): boolean {
|
||||
switch (filter) {
|
||||
case 'all':
|
||||
return true;
|
||||
// "Active" means moving bytes right now, which is not the same as "not stopped" — a seeding torrent
|
||||
// with no peers is running but idle, and lumping it in here makes the filter useless.
|
||||
case 'active':
|
||||
return t.rateDownload > 0 || t.rateUpload > 0;
|
||||
case 'downloading':
|
||||
return t.status === STATUS.Download || t.status === STATUS.DownloadWait;
|
||||
case 'seeding':
|
||||
return t.status === STATUS.Seed || t.status === STATUS.SeedWait;
|
||||
case 'paused':
|
||||
return t.status === STATUS.Stopped;
|
||||
case 'finished':
|
||||
return t.percentDone >= 1;
|
||||
case 'checking':
|
||||
return t.status === STATUS.Check || t.status === STATUS.CheckWait;
|
||||
case 'error':
|
||||
return t.error !== 0;
|
||||
}
|
||||
}
|
||||
|
||||
const compare = (a: Torrent, b: Torrent, key: SortKey): number => {
|
||||
const av = a[key as keyof Torrent];
|
||||
const bv = b[key as keyof Torrent];
|
||||
if (typeof av === 'string' || typeof bv === 'string') {
|
||||
return String(av ?? '').localeCompare(String(bv ?? ''), undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
return Number(av ?? 0) - Number(bv ?? 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies the filters and the sort. Pure, and memoised by the caller — 43 torrents is nothing but the list
|
||||
* re-renders every 5 seconds forever, and this also runs on every keystroke in the search box.
|
||||
*/
|
||||
export function selectTorrents(torrents: Torrent[], filters: TorrentFilters): Torrent[] {
|
||||
const needle = filters.q.trim().toLowerCase();
|
||||
|
||||
const out = torrents.filter((t) => {
|
||||
if (!matchesStatus(t, filters.status)) return false;
|
||||
if (filters.errorOnly && t.error === 0) return false;
|
||||
if (filters.label && !t.labels.includes(filters.label)) return false;
|
||||
if (filters.tracker && t.officerTrackerHost !== filters.tracker) return false;
|
||||
if (filters.downloadDir && t.downloadDir !== filters.downloadDir) return false;
|
||||
if (needle && !t.name.toLowerCase().includes(needle) && !t.labels.some((l) => l.toLowerCase().includes(needle)))
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
out.sort((a, b) => {
|
||||
const primary = compare(a, b, filters.sort);
|
||||
// Ties on a coarse key (status, priority, a rate of zero) would otherwise reshuffle on every poll,
|
||||
// because Array.sort is only stable with respect to the input order and the input is a fresh fetch.
|
||||
return (filters.desc ? -primary : primary) || a.id - b.id;
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The distinct values the nav offers as filters, each with how many torrents carry it. */
|
||||
export type FacetEntry = { value: string; count: number };
|
||||
|
||||
export function buildFacets(torrents: Torrent[]) {
|
||||
const labels = new Map<string, number>();
|
||||
const trackers = new Map<string, number>();
|
||||
const dirs = new Map<string, number>();
|
||||
let errors = 0;
|
||||
|
||||
for (const t of torrents) {
|
||||
for (const label of t.labels) labels.set(label, (labels.get(label) ?? 0) + 1);
|
||||
if (t.officerTrackerHost) trackers.set(t.officerTrackerHost, (trackers.get(t.officerTrackerHost) ?? 0) + 1);
|
||||
if (t.downloadDir) dirs.set(t.downloadDir, (dirs.get(t.downloadDir) ?? 0) + 1);
|
||||
if (t.error !== 0) errors += 1;
|
||||
}
|
||||
|
||||
const toSorted = (m: Map<string, number>): FacetEntry[] =>
|
||||
[...m.entries()]
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
||||
|
||||
return { labels: toSorted(labels), trackers: toSorted(trackers), dirs: toSorted(dirs), errors };
|
||||
}
|
||||
|
||||
/** Per-status counts for the nav badges, computed in one pass rather than eight filters. */
|
||||
export function countByStatus(torrents: Torrent[]): Record<StatusFilterId, number> {
|
||||
const counts = Object.fromEntries(STATUS_FILTERS.map((f) => [f.id, 0])) as Record<StatusFilterId, number>;
|
||||
for (const t of torrents) {
|
||||
for (const f of STATUS_FILTERS) if (matchesStatus(t, f.id)) counts[f.id] += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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 { post } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const portTest = useMutation({
|
||||
mutationFn: () => post<{ 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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { DEFAULT_TRANSMISSION_SECTION, isTransmissionSection, type TransmissionSectionId } from './shared';
|
||||
|
||||
// The URL names the open section — not a panel channel. See docs/navigation-audit.md. TransmissionScreen
|
||||
// redirects anything unrecognised, so the fallback here only covers the instant before that lands.
|
||||
|
||||
export function useTransmissionSection(): TransmissionSectionId {
|
||||
const { section } = useParams();
|
||||
return isTransmissionSection(section) ? section : DEFAULT_TRANSMISSION_SECTION;
|
||||
}
|
||||
@@ -30,6 +30,14 @@ export { CodeEditorView } from './apps/CodeEditor';
|
||||
// The route helpers, so the /headscale screen and the nav agree on one spelling of the section URL.
|
||||
export { DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from './apps/Headscale/shared';
|
||||
export type { HeadscaleSectionId } from './apps/Headscale/shared';
|
||||
|
||||
// Same for /transmission.
|
||||
export {
|
||||
DEFAULT_TRANSMISSION_SECTION,
|
||||
transmissionSectionPath,
|
||||
isTransmissionSection,
|
||||
} from './apps/Transmission/shared';
|
||||
export type { TransmissionSectionId } from './apps/Transmission/shared';
|
||||
export {
|
||||
useFilesAPI,
|
||||
useTasks,
|
||||
|
||||
Reference in New Issue
Block a user