transmission, extracted into plugins/transmission — the platform half
Sixth extraction, straight after photos. Everything transmission now lives at gitea.officer.dev/plugins/transmission. Deleted, not commented: the sidecar, the api router, the Transmission app (28 files including detail/ and dialogs/), the screen, the App.tsx route pair, the Dashboard barrel line, the usePageTitle rule, the AppRegistry wiring, the officerdev barrel re-exports, the permission registry entry, the hono.ts import and mount comments, and the app-store catalogue entry. No database change: transmission owns no tables. Its connection lives in service_connections, a core table it now depends on from outside — a real coupling, recorded in the plugin's own PLUGIN.md rather than left to be discovered. ── Test fixtures, repointed rather than deleted ── Transmission was the canonical `members: 'none'` fixture — a single-tenant daemon with no user concept — across three app-store test files. slskd has the identical shape, so the assertions moved there. Deleting them would have quietly dropped coverage of the no-members install path along with the plugin, which is the same trap the vault extraction hit with `members: 'invite'`. ── Vocabulary swept, per the runbook ── Six screens and two App.tsx comments pointed at `/photos` or `/transmission` as the worked example of the Workspace/Panel shape. Both are plugins now, so those pointers dangled at code no longer in the tree. Repointed at /command-hq and /soulseek, which are core. bunx tsgo clean. 1080 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,7 +50,7 @@ export function App() {
|
|||||||
<Dashboard.DashboardLayout>
|
<Dashboard.DashboardLayout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard.HomeScreen />} />
|
<Route path="/" element={<Dashboard.HomeScreen />} />
|
||||||
{/* Route pairs, like /offscale (a plugin now) and /photos below: the bare page route exists so the
|
{/* Route pairs, like /command-hq and /soulseek below: the bare page route exists so the
|
||||||
existing links into it keep working, and its `SettingsRoute` guard redirects to the
|
existing links into it keep working, and its `SettingsRoute` guard redirects to the
|
||||||
first section. The section is the URL rather than a `*_SELECTED` global, so a settings
|
first section. The section is the URL rather than a `*_SELECTED` global, so a settings
|
||||||
sub-page is linkable, bookmarkable and cmd-clickable. */}
|
sub-page is linkable, bookmarkable and cmd-clickable. */}
|
||||||
@@ -95,7 +95,7 @@ export function App() {
|
|||||||
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
||||||
{installedPlugins.flatMap((plugin) => [
|
{installedPlugins.flatMap((plugin) => [
|
||||||
<Route key={plugin.appName} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
<Route key={plugin.appName} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
||||||
// The section pair, exactly as the core screens do it (`/photos/:section`): the plugin's
|
// The section pair, exactly as the core screens do it (`/soulseek/:section`): the plugin's
|
||||||
// panels read `useParams` themselves, so which section is open is the URL rather than state
|
// panels read `useParams` themselves, so which section is open is the URL rather than state
|
||||||
// passed between them.
|
// passed between them.
|
||||||
<Route
|
<Route
|
||||||
@@ -106,8 +106,6 @@ export function App() {
|
|||||||
])}
|
])}
|
||||||
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
<Route path="/jellyfin" element={<Dashboard.JellyfinScreen />} />
|
||||||
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
<Route path="/jellyfin/:section" element={<Dashboard.JellyfinScreen />} />
|
||||||
<Route path="/transmission" element={<Dashboard.TransmissionScreen />} />
|
|
||||||
<Route path="/transmission/:section" element={<Dashboard.TransmissionScreen />} />
|
|
||||||
<Route path="/gitea" element={<Dashboard.GiteaScreen />} />
|
<Route path="/gitea" element={<Dashboard.GiteaScreen />} />
|
||||||
<Route path="/gitea/:section" element={<Dashboard.GiteaScreen />} />
|
<Route path="/gitea/:section" element={<Dashboard.GiteaScreen />} />
|
||||||
<Route path="/gitea/repo/:owner/:name" element={<Dashboard.GiteaScreen />} />
|
<Route path="/gitea/repo/:owner/:name" element={<Dashboard.GiteaScreen />} />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { WorkspaceView } from 'officerdev';
|
|||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
// /calendar uses the Workspace/Panel system (like /transmission): the calendar list on the left, the
|
// /calendar uses the Workspace/Panel system (like /command-hq): the calendar list on the left, the
|
||||||
// selected calendar's agenda on the right. Both panels read the officer-caldav sidecar through the
|
// selected calendar's agenda on the right. Both panels read the officer-caldav sidecar through the
|
||||||
// /api/caldav auth proxy, which holds no DAV credentials of its own.
|
// /api/caldav auth proxy, which holds no DAV credentials of its own.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { WorkspaceView, DEFAULT_GITEA_SECTION, giteaSectionPath, isGiteaSection
|
|||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
// /gitea uses the Workspace/Panel system (like /transmission): a section nav on the left and the section view
|
// /gitea uses the Workspace/Panel system (like /command-hq): a section nav on the left and the section view
|
||||||
// on the right. Both talk to the officer-gitea sidecar through the /api/gitea auth proxy, which holds no
|
// on the right. Both talk to the officer-gitea sidecar through the /api/gitea auth proxy, which holds no
|
||||||
// Gitea credentials of its own — the instance URL and the access token live in the sidecar.
|
// Gitea credentials of its own — the instance URL and the access token live in the sidecar.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { DEFAULT_INVOICES_SECTION, invoicesSectionPath, isInvoicesSection, Works
|
|||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
// /invoices uses the Workspace/Panel system (like /transmission and /offscale): the section nav on the
|
// /invoices uses the Workspace/Panel system (like /command-hq): the section nav on the
|
||||||
// left, the section itself on the right. Both panels talk to the officer-invoiceshelf sidecar through the
|
// left, the section itself on the right. Both panels talk to the officer-invoiceshelf sidecar through the
|
||||||
// /api/invoiceshelf auth proxy — the InvoiceShelf URL, its Sanctum token and the company header live in the
|
// /api/invoiceshelf auth proxy — the InvoiceShelf URL, its Sanctum token and the company header live in the
|
||||||
// sidecar and never reach the browser.
|
// sidecar and never reach the browser.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { WorkspaceView, DEFAULT_SOULSEEK_SECTION, soulseekSectionPath, isSoulsee
|
|||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
// /soulseek uses the Workspace/Panel system (like /photos): a section nav (soulseek-nav) on the left and
|
// /soulseek uses the Workspace/Panel system (like /command-hq): a section nav (soulseek-nav) on the left and
|
||||||
// a section view (soulseek-view) on the right, both reading `:section` from the URL. Deeper selection —
|
// a section view (soulseek-view) on the right, both reading `:section` from the URL. Deeper selection —
|
||||||
// which search, which peer — is the query string. Both talk to slskd through the /api/slskd auth proxy.
|
// which search, which peer — is the query string. Both talk to slskd through the /api/slskd auth proxy.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { WorkspaceView, DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope
|
|||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
// /system-monitor uses the Workspace/Panel system (like /photos): the scope list on the left, the scope
|
// /system-monitor uses the Workspace/Panel system (like /command-hq): the scope list on the left, the scope
|
||||||
// itself on the right. Which scope is open is `:scope` in the URL, not a panel channel.
|
// itself on the right. Which scope is open is `:scope` in the URL, not a panel channel.
|
||||||
|
|
||||||
export const SystemMonitorScreen = () => {
|
export const SystemMonitorScreen = () => {
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
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 /offscale): 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.
|
|
||||||
|
|
||||||
export const TransmissionScreen = () => {
|
|
||||||
const { section } = useParams();
|
|
||||||
const workspace = useDashboardState<LayoutNode>('screens/transmission', defaultLayout);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
appTypes={{ allowed: ['transmission-nav', 'transmission-view'], fallback: 'transmission-view' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './TransmissionScreen';
|
|
||||||
@@ -4,7 +4,7 @@ import { WorkspaceView, DEFAULT_WALLET_SECTION, walletSectionPath, isWalletSecti
|
|||||||
import { useDashboardState } from 'state/useDashboardState';
|
import { useDashboardState } from 'state/useDashboardState';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
// /wallet uses the Workspace/Panel system (like /transmission): the wallet list and section nav on the left,
|
// /wallet uses the Workspace/Panel system (like /command-hq): the wallet list and section nav on the left,
|
||||||
// the section itself on the right. Both talk to the officer-wallet sidecar through the /api/wallet auth
|
// the section itself on the right. Both talk to the officer-wallet sidecar through the /api/wallet auth
|
||||||
// proxy, which holds no key material of its own — seeds live encrypted in the sidecar and are decrypted
|
// proxy, which holds no key material of its own — seeds live encrypted in the sidecar and are decrypted
|
||||||
// there only for the length of an unlock window.
|
// there only for the length of an unlock window.
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export * from './Contacts';
|
|||||||
export * from './Soulseek';
|
export * from './Soulseek';
|
||||||
export * from './CommandHQ';
|
export * from './CommandHQ';
|
||||||
export * from './Jellyfin';
|
export * from './Jellyfin';
|
||||||
export * from './Transmission';
|
|
||||||
export * from './Gitea';
|
export * from './Gitea';
|
||||||
export * from './Invoices';
|
export * from './Invoices';
|
||||||
export * from './Wallet';
|
export * from './Wallet';
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const RULES: TitleRule[] = [
|
|||||||
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
{ match: (p) => p.startsWith('/jellyfin'), title: 'Video' },
|
||||||
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
{ match: (p) => p.startsWith('/soulseek'), title: 'Soulseek' },
|
||||||
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
|
{ match: (p) => p.startsWith('/command-hq'), title: 'Command HQ' },
|
||||||
{ match: (p) => p.startsWith('/transmission'), title: 'Transmission' },
|
|
||||||
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
|
{ match: (p) => p.startsWith('/gitea'), title: 'Gitea' },
|
||||||
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
|
{ match: (p) => p.startsWith('/invoices'), title: 'Invoices' },
|
||||||
{ match: (p) => p.startsWith('/wallet'), title: 'Wallet' },
|
{ match: (p) => p.startsWith('/wallet'), title: 'Wallet' },
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
|
||||||
|
|
||||||
// /api/transmission/* — auth, then forward to officer-transmission. No routes of its own and no transmission knowledge:
|
|
||||||
// this file must never grow app logic.
|
|
||||||
//
|
|
||||||
// The sidecar owns the Transmission RPC contract, including its session-id handshake, and holds the
|
|
||||||
// credentials. The platform knows none of it.
|
|
||||||
|
|
||||||
const proxy = createSidecarProxy({
|
|
||||||
name: 'transmission',
|
|
||||||
prefix: '/api/transmission',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const transmissionRouter = proxy.router;
|
|
||||||
|
|
||||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
|
||||||
export const getTransmissionServerUrl = proxy.getHttpUrl;
|
|
||||||
@@ -107,9 +107,10 @@ describe('member provisioning is declared for every entry', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('never promises accounts for a single-tenant daemon', () => {
|
it('never promises accounts for a single-tenant daemon', () => {
|
||||||
// Transmission and slskd have no user concept; claiming otherwise would make the installer try to
|
// slskd has no user concept; claiming otherwise would make the installer try to create accounts
|
||||||
// create accounts against an API that does not exist.
|
// against an API that does not exist. (Transmission was the other example until 2026-08-18, when it
|
||||||
for (const id of ['transmission', 'slskd']) expect(byId(id)!.members).toBe('none');
|
// became `plugins/transmission/`.)
|
||||||
|
for (const id of ['slskd']) expect(byId(id)!.members).toBe('none');
|
||||||
});
|
});
|
||||||
|
|
||||||
// A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become
|
// A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become
|
||||||
|
|||||||
@@ -221,48 +221,11 @@ export const CATALOGUE: CatalogueEntry[] = [
|
|||||||
// gets a row there (its install state is `plugin_installs`), so a leftover entry would make the
|
// gets a row there (its install state is `plugin_installs`), so a leftover entry would make the
|
||||||
// permission permanently `unavailable`, put its route into `deniedRoutes`, and withhold the dock tile —
|
// permission permanently `unavailable`, put its route into `deniedRoutes`, and withhold the dock tile —
|
||||||
// on a server where the plugin is installed and working.
|
// on a server where the plugin is installed and working.
|
||||||
{
|
// Transmission was here until 2026-08-18, when it became `plugins/transmission/`.
|
||||||
id: 'transmission',
|
//
|
||||||
ui: {
|
// Deleting the entry is REQUIRED, not tidying — see the note where photos used to sit, and the trap in
|
||||||
name: 'Transmission',
|
// `EXTRACTING-A-PLUGIN.md`. A leftover entry makes the permission permanently `unavailable`, because
|
||||||
icon: 'ArrowDownUp',
|
// `permissionAvailability()` derives from `sidecar_installs` and a plugin never gets a row there.
|
||||||
color: '#e11d48',
|
|
||||||
rootRoute: '/transmission',
|
|
||||||
routes: ['/transmission'],
|
|
||||||
},
|
|
||||||
process: 'officer-transmission',
|
|
||||||
label: 'Transmission',
|
|
||||||
summary: 'Torrents, with the daemon Officer talks to over RPC',
|
|
||||||
members: 'none',
|
|
||||||
modes: ['existing', 'provisioned'],
|
|
||||||
permission: 'transmission',
|
|
||||||
composeTemplate: 'transmission',
|
|
||||||
existingFields: [
|
|
||||||
{
|
|
||||||
key: 'url',
|
|
||||||
label: 'Transmission URL',
|
|
||||||
type: 'url',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'https://transmission.example.com or http://10.0.0.5:9091',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'path',
|
|
||||||
label: 'RPC path',
|
|
||||||
type: 'text',
|
|
||||||
required: false,
|
|
||||||
placeholder: '/transmission/rpc',
|
|
||||||
help: 'Only differs behind a reverse proxy.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'username',
|
|
||||||
label: 'RPC username',
|
|
||||||
type: 'text',
|
|
||||||
required: false,
|
|
||||||
help: 'Usually blank — Transmission is normally run with no RPC auth.',
|
|
||||||
},
|
|
||||||
{ key: 'secret', label: 'RPC password', type: 'secret', required: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'slskd',
|
id: 'slskd',
|
||||||
ui: {
|
ui: {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { byId } from './catalogue';
|
|||||||
// when something goes wrong: resume, blocking, and not doing work twice.
|
// when something goes wrong: resume, blocking, and not doing work twice.
|
||||||
|
|
||||||
const jellyfin = byId('jellyfin')!; // existing|provisioned, members: 'accounts'
|
const jellyfin = byId('jellyfin')!; // existing|provisioned, members: 'accounts'
|
||||||
const transmission = byId('transmission')!; // existing|provisioned, members: 'none'
|
const slskd = byId('slskd')!; // existing|provisioned, members: 'none'
|
||||||
const configOnly = byId('notify')!; // config only
|
const configOnly = byId('notify')!; // config only
|
||||||
|
|
||||||
/** Records what was actually called, so a test can assert on absence as well as presence. */
|
/** Records what was actually called, so a test can assert on absence as well as presence. */
|
||||||
@@ -46,7 +46,7 @@ describe('planSteps', () => {
|
|||||||
it('omits the members step for a service with no user concept', () => {
|
it('omits the members step for a service with no user concept', () => {
|
||||||
// Otherwise a Transmission install reports a members step that did nothing, which reads as a
|
// Otherwise a Transmission install reports a members step that did nothing, which reads as a
|
||||||
// silent failure to anyone debugging why a member has no access.
|
// silent failure to anyone debugging why a member has no access.
|
||||||
expect(planSteps(transmission, 'provisioned')).not.toContain('members');
|
expect(planSteps(slskd, 'provisioned')).not.toContain('members');
|
||||||
expect(planSteps(jellyfin, 'provisioned')).toContain('members');
|
expect(planSteps(jellyfin, 'provisioned')).toContain('members');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ describe('what adding a member would do', () => {
|
|||||||
it('predicts the outcome per service without contacting anything', () => {
|
it('predicts the outcome per service without contacting anything', () => {
|
||||||
expect(plannedOutcome(byId('jellyfin')!)).toBe('provisioned');
|
expect(plannedOutcome(byId('jellyfin')!)).toBe('provisioned');
|
||||||
expect(plannedOutcome(inviteOnly)).toBe('invited');
|
expect(plannedOutcome(inviteOnly)).toBe('invited');
|
||||||
expect(plannedOutcome(byId('transmission')!)).toBe('not-applicable');
|
expect(plannedOutcome(byId('slskd')!)).toBe('not-applicable');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('which installed services have work to do', () => {
|
describe('which installed services have work to do', () => {
|
||||||
it('skips single-tenant daemons entirely', () => {
|
it('skips single-tenant daemons entirely', () => {
|
||||||
const ids = servicesNeedingMemberProvision(['jellyfin', 'transmission', 'slskd', 'memos']).map((e) => e.id);
|
const ids = servicesNeedingMemberProvision(['jellyfin', 'slskd', 'memos']).map((e) => e.id);
|
||||||
expect(ids.sort()).toEqual(['jellyfin', 'memos']);
|
expect(ids.sort()).toEqual(['jellyfin', 'memos']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { pluginsRouter } from './api/plugins/router';
|
|||||||
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
||||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||||
// import { slskdRouter } from './api/slskd/router';
|
// import { slskdRouter } from './api/slskd/router';
|
||||||
// import { transmissionRouter } from './api/transmission/router';
|
|
||||||
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
// import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
||||||
// import { jellyfinRouter } from './api/jellyfin/router';
|
// import { jellyfinRouter } from './api/jellyfin/router';
|
||||||
// import { walletRouter } from './api/wallet/router';
|
// import { walletRouter } from './api/wallet/router';
|
||||||
@@ -151,7 +150,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
|||||||
// ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13
|
// ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI — plugin, switched off 2026-08-13
|
||||||
// ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off
|
// ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) — plugin, switched off
|
||||||
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
|
// ['/notify', notifyRouter], // plugin — switched off 2026-08-13
|
||||||
// ['/transmission', transmissionRouter], // plugin — switched off 2026-08-13
|
|
||||||
// ['/invoiceshelf', invoiceshelfRouter], // plugin — switched off 2026-08-13
|
// ['/invoiceshelf', invoiceshelfRouter], // plugin — switched off 2026-08-13
|
||||||
// ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13
|
// ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13
|
||||||
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
|
// ['/photos', photosRouter], // plugin — switched off 2026-08-13
|
||||||
|
|||||||
@@ -200,15 +200,6 @@ const CORE_REGISTRY: Permission[] = [
|
|||||||
// Device registration is the caller's own — a phone subscribing to its own push channel.
|
// Device registration is the caller's own — a phone subscribing to its own push channel.
|
||||||
personal: ['/devices', '/subscriptions'],
|
personal: ['/devices', '/subscriptions'],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'transmission',
|
|
||||||
label: 'Transmission',
|
|
||||||
description: 'Torrent downloads',
|
|
||||||
kind: 'app',
|
|
||||||
// api: ['/transmission'], // plugin — switched off 2026-08-13
|
|
||||||
api: [],
|
|
||||||
routes: ['/transmission'],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'soulseek',
|
key: 'soulseek',
|
||||||
label: 'Soulseek',
|
label: 'Soulseek',
|
||||||
|
|||||||
@@ -129,6 +129,26 @@ const BUNDLED: CatalogueEntry[] = [
|
|||||||
// No osDependencies: the sidecar is a Bun HTTP proxy and reaches Immich with `fetch`.
|
// No osDependencies: the sidecar is a Bun HTTP proxy and reaches Immich with `fetch`.
|
||||||
has: { api: true, schema: true, sidecar: true, web: true },
|
has: { api: true, schema: true, sidecar: true, web: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
appName: 'transmission',
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
label: 'Transmission',
|
||||||
|
summary: 'Torrent downloads — queue, files, peers and speed, from your own daemon',
|
||||||
|
color: '#dc2626',
|
||||||
|
icon: 'Download',
|
||||||
|
source: 'https://gitea.officer.dev/plugins/transmission.git',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'transmission',
|
||||||
|
label: 'Transmission',
|
||||||
|
description: 'Torrent downloads',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// `schema: false` — the connection lives in `service_connections`, a core table. See its manifest.
|
||||||
|
has: { api: true, schema: false, sidecar: true, web: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
appName: 'vault',
|
appName: 'vault',
|
||||||
publisher: 'officerdev',
|
publisher: 'officerdev',
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
|
|
||||||
import { probe } from './rpc';
|
|
||||||
import {
|
|
||||||
basicAuth,
|
|
||||||
getTransmissionConfig,
|
|
||||||
invalidateTransmissionConfig,
|
|
||||||
normalizeBase,
|
|
||||||
normalizeRpcPath,
|
|
||||||
} from './upstream';
|
|
||||||
|
|
||||||
// `/_config` — where the Transmission daemon is, driven from the app rather than from a shell on the server.
|
|
||||||
//
|
|
||||||
// ONE connection, not a registry: nobody runs two Transmission daemons. So there is no label, no id in the
|
|
||||||
// path and no activate route — a save is an upsert over the single row, and the UI is one form.
|
|
||||||
//
|
|
||||||
// The URL is the only thing usually needed. Most Transmission daemons run with no RPC auth at all, so
|
|
||||||
// username/password are optional and an EMPTY username means "no auth" rather than "empty credentials" —
|
|
||||||
// see basicAuth in upstream.ts, where sending a Basic header anyway makes the daemon reject the request.
|
|
||||||
// `rpcPath` is there for the reverse-proxy case and defaults to /transmission/rpc.
|
|
||||||
//
|
|
||||||
// The password is WRITE-ONLY across this boundary: the GET reports whether one is stored, never its value.
|
|
||||||
// A save is validated with a live session-get first, so a wrong URL or rejected credentials is a 400 with
|
|
||||||
// the reason rather than a stored row that makes every later screen fail mysteriously.
|
|
||||||
|
|
||||||
/** What the browser is allowed to know about the connection. Never includes the password. */
|
|
||||||
async function connectionState(userId: number): Promise<Response> {
|
|
||||||
const connection = await getServiceConnection(userId, 'transmission');
|
|
||||||
return Response.json({ configured: !!connection, connection });
|
|
||||||
}
|
|
||||||
|
|
||||||
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
|
||||||
|
|
||||||
type ConnectionBody = { url?: unknown; username?: unknown; password?: unknown; rpcPath?: unknown };
|
|
||||||
|
|
||||||
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
|
|
||||||
|
|
||||||
const readString = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');
|
|
||||||
|
|
||||||
/** Save the connection: validated against the live daemon, then stored. */
|
|
||||||
async function save(req: Request, userId: number): Promise<Response> {
|
|
||||||
const body = ((await req.json().catch(() => null)) as ConnectionBody | null) ?? {};
|
|
||||||
const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
|
|
||||||
const username = readString(body.username);
|
|
||||||
const rpcPath = normalizeRpcPath(readString(body.rpcPath));
|
|
||||||
// A blank password with a username still set means "keep the stored one", so changing the URL does not
|
|
||||||
// need the password re-typed. Clearing the username clears the auth entirely.
|
|
||||||
const password = typeof body.password === 'string' && body.password ? body.password : null;
|
|
||||||
|
|
||||||
if (!url) return bad('url is required');
|
|
||||||
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
|
|
||||||
|
|
||||||
const current = await getServiceConnection(userId, 'transmission');
|
|
||||||
const keepPassword = !password && !!username && username === current?.username && current.hasSecret;
|
|
||||||
|
|
||||||
// Probing needs the real password, so a save that keeps the stored one borrows it through the normal read
|
|
||||||
// path rather than reaching for the ciphertext here.
|
|
||||||
const stored = keepPassword ? await getTransmissionConfig(userId) : null;
|
|
||||||
const auth = keepPassword ? (stored?.auth ?? null) : basicAuth(username || null, password);
|
|
||||||
|
|
||||||
const result = await probe({ base: url, rpcPath, auth });
|
|
||||||
if (!result.ok) return bad(result.error ?? 'could not reach the daemon');
|
|
||||||
|
|
||||||
const connection = await saveServiceConnection({
|
|
||||||
userId,
|
|
||||||
service: 'transmission',
|
|
||||||
url,
|
|
||||||
username: username || null,
|
|
||||||
// undefined keeps the stored password; null clears it, which is what dropping the username means.
|
|
||||||
secret: keepPassword ? undefined : (password ?? null),
|
|
||||||
path: rpcPath,
|
|
||||||
version: result.version ?? null,
|
|
||||||
});
|
|
||||||
invalidateTransmissionConfig(userId);
|
|
||||||
return Response.json({ connection, rpcVersion: result.rpcVersion ?? null });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `/_config` — GET the connection, PUT/POST to save it, DELETE to forget it. */
|
|
||||||
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
|
|
||||||
if (subpath && subpath !== '/') return bad('not found', 404);
|
|
||||||
|
|
||||||
if (req.method === 'GET') return connectionState(userId);
|
|
||||||
if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);
|
|
||||||
|
|
||||||
if (req.method === 'DELETE') {
|
|
||||||
await deleteServiceConnection(userId, 'transmission');
|
|
||||||
invalidateTransmissionConfig(userId);
|
|
||||||
return connectionState(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return bad('method not allowed', 405);
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
|
||||||
import { createSidecarConnector } from '../connect';
|
|
||||||
import { handleConfigRoute } from './config';
|
|
||||||
import { handleOfficerRoute } from './routes';
|
|
||||||
import { probe } from './rpc';
|
|
||||||
import { getTransmissionConfig } from './upstream';
|
|
||||||
import { API_URL } from '../../officer-url.mjs';
|
|
||||||
|
|
||||||
// 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 /_config { configured, connection } — where the daemon is, never the password
|
|
||||||
// PUT /_config { url, username?, password?, rpcPath? } — validated with a live
|
|
||||||
// session-get, then stored. A blank password keeps the stored one
|
|
||||||
// DEL /_config forget the connection
|
|
||||||
// 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
|
|
||||||
// GET /_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.
|
|
||||||
//
|
|
||||||
// Every route needs `X-Officer-User`, which the platform proxy sets 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, and which daemon to talk to is per-owner data.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** 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);
|
|
||||||
|
|
||||||
const officerUser = req.headers.get('X-Officer-User');
|
|
||||||
const userId = Number(officerUser);
|
|
||||||
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
|
|
||||||
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
|
|
||||||
try {
|
|
||||||
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[transmission] ${req.method} ${url.pathname} failed`, err);
|
|
||||||
return Response.json({ error: 'internal error' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
|
|
||||||
// configured-but-unreachable daemon is the whole reason the flag is on the response.
|
|
||||||
if (url.pathname === '/_health') {
|
|
||||||
const cfg = await getTransmissionConfig(userId);
|
|
||||||
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
|
|
||||||
const started = Date.now();
|
|
||||||
const result = await probe(cfg);
|
|
||||||
return Response.json(
|
|
||||||
{ ...result, configured: true, ms: Date.now() - started },
|
|
||||||
{ status: result.ok ? 200 : 502 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname.startsWith('/_officer/')) {
|
|
||||||
try {
|
|
||||||
const res = await handleOfficerRoute(userId, 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 });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// No upstream in the banner: where the daemon lives is now per-owner state read from the database per
|
|
||||||
// request, not a constant this process knows at boot.
|
|
||||||
console.log(`[transmission] listening on 127.0.0.1:${port}`);
|
|
||||||
|
|
||||||
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',
|
|
||||||
handles: ['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'));
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
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.
|
|
||||||
*
|
|
||||||
* `userId` was authenticated by the platform proxy and validated at the server boundary — every call below
|
|
||||||
* needs it, because which daemon to talk to is per-owner state read from the database.
|
|
||||||
*/
|
|
||||||
export async function handleOfficerRoute(userId: number, req: Request, url: URL): Promise<Response | null> {
|
|
||||||
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>(ctx.userId, '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(ctx.userId, '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>(ctx.userId, '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>(ctx.userId, '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 }>(ctx.userId, 'free-space', { path });
|
|
||||||
return Response.json({ path: result.path, bytes: result['size-bytes'] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET: it asks the daemon whether the peer port is reachable and returns the answer. Nothing changes,
|
|
||||||
// on this side or Transmission's. Blocklist-update below stays POST because it genuinely refetches and
|
|
||||||
// replaces the list — the two look alike and are not.
|
|
||||||
async function handlePortTest(ctx: OfficerContext): Promise<Response> {
|
|
||||||
if (ctx.req.method !== 'GET') return methodNotAllowed();
|
|
||||||
const result = await rpc<{ 'port-is-open': boolean }>(ctx.userId, '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 }>(ctx.userId, '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[] }>(ctx.userId, '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(ctx);
|
|
||||||
case 'action':
|
|
||||||
return await runAction(ctx);
|
|
||||||
case 'set':
|
|
||||||
return await setTorrent(ctx);
|
|
||||||
case 'location':
|
|
||||||
return await setLocation(ctx);
|
|
||||||
case 'rename':
|
|
||||||
return await renamePath(ctx);
|
|
||||||
case 'remove':
|
|
||||||
return await removeTorrents(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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[] }>(ctx.userId, '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(ctx: OfficerContext): Promise<Response> {
|
|
||||||
const body = await readJson(ctx.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 };
|
|
||||||
}>(ctx.userId, '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(ctx: OfficerContext): Promise<Response> {
|
|
||||||
const body = await readJson(ctx.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(ctx.userId, method, { ids });
|
|
||||||
return Response.json({ ok: true, affected: ids.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setTorrent(ctx: OfficerContext): Promise<Response> {
|
|
||||||
const body = await readJson(ctx.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(ctx.userId, 'torrent-set', { ids, ...fields });
|
|
||||||
return Response.json({ ok: true, affected: ids.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setLocation(ctx: OfficerContext): Promise<Response> {
|
|
||||||
const body = await readJson(ctx.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(ctx.userId, 'torrent-set-location', { ids, location, move });
|
|
||||||
return Response.json({ ok: true, affected: ids.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renamePath(ctx: OfficerContext): Promise<Response> {
|
|
||||||
const body = await readJson(ctx.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(ctx.userId, 'torrent-rename-path', { ids: [id], path, name });
|
|
||||||
return Response.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeTorrents(ctx: OfficerContext): Promise<Response> {
|
|
||||||
const body = await readJson(ctx.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(ctx.userId, 'torrent-remove', { ids, 'delete-local-data': deleteLocalData });
|
|
||||||
return Response.json({ ok: true, affected: ids.length, deletedData: deleteLocalData });
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import type { Torrent, TrackerStat } from './types';
|
|
||||||
import type { TransmissionConfig } from './upstream';
|
|
||||||
import { getTransmissionConfig } 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 basicAuth in upstream.ts.
|
|
||||||
// • `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 a daemon session, keyed by endpoint rather than by owner: the token belongs to the
|
|
||||||
// daemon, so two owners pointed at the same one share a refresh, and re-pointing at a different daemon
|
|
||||||
// cannot carry a token that daemon never issued.
|
|
||||||
const sessionIds = new Map<string, string>();
|
|
||||||
|
|
||||||
type RpcResponse<T> = { result: string; arguments?: T };
|
|
||||||
|
|
||||||
/** Issue one RPC call against the owner's configured daemon. */
|
|
||||||
export async function rpc<T = unknown>(userId: number, method: string, args: Record<string, unknown> = {}): Promise<T> {
|
|
||||||
const cfg = await getTransmissionConfig(userId);
|
|
||||||
if (!cfg) throw new TransmissionError(503, 'transmission is not connected');
|
|
||||||
return call<T>(cfg, method, args, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The same call against a connection that may not be stored yet — used to validate one before saving it. */
|
|
||||||
export async function rpcWith<T = unknown>(
|
|
||||||
cfg: TransmissionConfig,
|
|
||||||
method: string,
|
|
||||||
args: Record<string, unknown> = {},
|
|
||||||
): Promise<T> {
|
|
||||||
return call<T>(cfg, method, args, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function call<T>(
|
|
||||||
cfg: TransmissionConfig,
|
|
||||||
method: string,
|
|
||||||
args: Record<string, unknown>,
|
|
||||||
mayRetry: boolean,
|
|
||||||
): Promise<T> {
|
|
||||||
const endpoint = `${cfg.base}${cfg.rpcPath}`;
|
|
||||||
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
||||||
const sessionId = sessionIds.get(endpoint);
|
|
||||||
if (sessionId) headers['X-Transmission-Session-Id'] = sessionId;
|
|
||||||
if (cfg.auth) headers['Authorization'] = cfg.auth;
|
|
||||||
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetch(endpoint, {
|
|
||||||
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) {
|
|
||||||
sessionIds.set(endpoint, fresh);
|
|
||||||
return call<T>(cfg, 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 and before a connection is saved. */
|
|
||||||
export async function probe(
|
|
||||||
cfg: TransmissionConfig,
|
|
||||||
): Promise<{ ok: boolean; version?: string; rpcVersion?: number; error?: string }> {
|
|
||||||
try {
|
|
||||||
const args = await rpcWith<{ version: string; 'rpc-version': number }>(cfg, '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;
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
// 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;
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { getServiceCredentials } from 'officerdb';
|
|
||||||
|
|
||||||
// Transmission upstream config for the officer-transmission sidecar.
|
|
||||||
//
|
|
||||||
// All knowledge of the daemon — its URL, its RPC path and its credentials — lives here. The platform API is
|
|
||||||
// a thin auth+forward proxy and holds NO Transmission credentials.
|
|
||||||
//
|
|
||||||
// The daemon is CONFIGURED BY THE OWNER FROM THE UI and stored in `service_connections` (see
|
|
||||||
// databases/officer_db/src/service-connections/queries.ts), no longer read from the environment: Bun
|
|
||||||
// auto-loads `.env` into every process started in the platform directory, so TRANSMISSION_* was also
|
|
||||||
// sitting in `officer`'s own process.env, and pointing Officer at a daemon meant editing a file on the
|
|
||||||
// server. Nothing in this file reads process.env.
|
|
||||||
|
|
||||||
/** Everything needed to make one RPC call. A candidate being validated has this and nothing else yet. */
|
|
||||||
export type TransmissionConfig = { base: string; rpcPath: string; auth: string | null };
|
|
||||||
|
|
||||||
/** Trailing slashes off, so `${base}${rpcPath}` never doubles the separator. */
|
|
||||||
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The RPC endpoint path. Transmission serves it at /transmission/rpc by default, but a reverse proxy can
|
|
||||||
* mount it anywhere, so it is stored per connection rather than hardcoded.
|
|
||||||
*/
|
|
||||||
export const normalizeRpcPath = (raw: string | null | undefined): string => {
|
|
||||||
const path = raw?.trim();
|
|
||||||
if (!path) return '/transmission/rpc';
|
|
||||||
return path.startsWith('/') ? path : `/${path}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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, which is why the stored username is nullable rather than defaulting to ''.
|
|
||||||
*/
|
|
||||||
export function basicAuth(username: string | null, password: string | null): string | null {
|
|
||||||
const user = username?.trim();
|
|
||||||
if (!user) return null;
|
|
||||||
return `Basic ${Buffer.from(`${user}:${password ?? ''}`).toString('base64')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The torrent list polls every couple of seconds, so the row is cached rather than re-read per request.
|
|
||||||
// Writes invalidate immediately; the TTL only covers someone editing the row in psql, which then takes
|
|
||||||
// effect within a minute instead of needing a restart.
|
|
||||||
const TTL_MS = 60_000;
|
|
||||||
const cache = new Map<number, { cfg: TransmissionConfig | null; at: number }>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The owner's Transmission connection, or null when nothing is configured — the sidecar then answers 503,
|
|
||||||
* and the UI turns that into the setup form rather than an empty torrent list.
|
|
||||||
*/
|
|
||||||
export async function getTransmissionConfig(userId: number): Promise<TransmissionConfig | null> {
|
|
||||||
const hit = cache.get(userId);
|
|
||||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
|
|
||||||
|
|
||||||
const creds = await getServiceCredentials(userId, 'transmission');
|
|
||||||
// `url` became nullable when services that inherit one instance landed (see the schema column note).
|
|
||||||
// Transmission fronts a single daemon and always stores its own, so null here is a malformed row.
|
|
||||||
const cfg = creds?.url
|
|
||||||
? {
|
|
||||||
base: normalizeBase(creds.url),
|
|
||||||
rpcPath: normalizeRpcPath(creds.path),
|
|
||||||
auth: basicAuth(creds.username, creds.secret),
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
cache.set(userId, { cfg, at: Date.now() });
|
|
||||||
return cfg;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop the cached row — called by the config routes after any save or removal. */
|
|
||||||
export function invalidateTransmissionConfig(userId: number): void {
|
|
||||||
cache.delete(userId);
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
|||||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||||
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
import { appRegistryMetas as soulseekMetas } from '../apps/Soulseek';
|
||||||
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
|
import { appRegistryMetas as jellyfinMetas } from '../apps/Jellyfin';
|
||||||
import { appRegistryMetas as transmissionMetas } from '../apps/Transmission';
|
|
||||||
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
|
import { appRegistryMetas as giteaMetas } from '../apps/Gitea';
|
||||||
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
|
import { appRegistryMetas as invoicesMetas } from '../apps/Invoices';
|
||||||
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
|
import { appRegistryMetas as walletMetas } from '../apps/Wallet';
|
||||||
@@ -36,7 +35,6 @@ export const apps = [
|
|||||||
...desktopMetas,
|
...desktopMetas,
|
||||||
...soulseekMetas,
|
...soulseekMetas,
|
||||||
...jellyfinMetas,
|
...jellyfinMetas,
|
||||||
...transmissionMetas,
|
|
||||||
...giteaMetas,
|
...giteaMetas,
|
||||||
...invoicesMetas,
|
...invoicesMetas,
|
||||||
...walletMetas,
|
...walletMetas,
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
import type { ServiceConnection } from '../../hooks/useServiceConnection';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { CheckCircle2, Loader2, Plug, Trash2, TriangleAlert } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import {
|
|
||||||
serviceErrorMessage,
|
|
||||||
useServiceConnection,
|
|
||||||
useServiceConnectionActions,
|
|
||||||
useServiceHealth,
|
|
||||||
} from '../../hooks/useServiceConnection';
|
|
||||||
|
|
||||||
// Connecting a Transmission daemon to Officer, from the app.
|
|
||||||
//
|
|
||||||
// This screen is BOTH the setup wizard and the permanent settings page: TransmissionView renders it in place
|
|
||||||
// of whatever section the URL asks for while nothing is connected, and /transmission/connection renders it
|
|
||||||
// for good. One component, so re-pointing at a different daemon later goes through exactly the code path
|
|
||||||
// that stored the first one.
|
|
||||||
//
|
|
||||||
// ONE connection, not a registry — nobody runs two Transmission daemons. And usually one FIELD: most
|
|
||||||
// daemons run with no RPC auth at all, which is why username/password sit behind a disclosure rather than
|
|
||||||
// in the owner's way. An empty username means "no auth", not "empty credentials"; the sidecar is careful
|
|
||||||
// about that distinction because Transmission rejects a request carrying an empty Basic header.
|
|
||||||
|
|
||||||
const HINT = 'text-[11px] leading-relaxed text-muted-foreground';
|
|
||||||
|
|
||||||
/** Transmission's own default RPC port. Officer dials it from the server, not from this browser. */
|
|
||||||
const DEFAULT_URL = 'http://localhost:9091';
|
|
||||||
|
|
||||||
const URL_HINT =
|
|
||||||
"The daemon's base URL, without the RPC path. Officer reaches it from the server, not from this browser " +
|
|
||||||
'— so localhost here means the machine Officer runs on.';
|
|
||||||
|
|
||||||
const AUTH_HINT =
|
|
||||||
'Only if the daemon has rpc-authentication-required set. Leave the username empty for the usual case: an ' +
|
|
||||||
'empty username means no authentication, and sending a blank one anyway makes Transmission refuse the call.';
|
|
||||||
|
|
||||||
const PATH_HINT = 'Only differs behind a reverse proxy that mounts the RPC endpoint somewhere else.';
|
|
||||||
|
|
||||||
type FieldProps = {
|
|
||||||
label: string;
|
|
||||||
hint?: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder: string;
|
|
||||||
type?: string;
|
|
||||||
autoFocus?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Field = ({ label, hint, value, onChange, placeholder, type, autoFocus }: FieldProps) => (
|
|
||||||
<label className="flex flex-col gap-1.5">
|
|
||||||
<span className="text-xs font-medium">{label}</span>
|
|
||||||
<Input
|
|
||||||
value={value}
|
|
||||||
onChange={(ev) => onChange(ev.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
type={type}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
/>
|
|
||||||
{hint && <span className={HINT}>{hint}</span>}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
|
|
||||||
type SaveInput = Record<string, unknown> & { url: string };
|
|
||||||
|
|
||||||
export const ConnectionView = () => {
|
|
||||||
const { data, isLoading } = useServiceConnection('transmission');
|
|
||||||
const { data: health } = useServiceHealth('transmission');
|
|
||||||
const { save, forget } = useServiceConnectionActions<SaveInput>('transmission');
|
|
||||||
|
|
||||||
const connection = data?.connection ?? null;
|
|
||||||
|
|
||||||
const [url, setUrl] = useState('');
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [rpcPath, setRpcPath] = useState('');
|
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
// Seed the form from the stored row once it arrives. Keyed on the row's id so a save (which returns the
|
|
||||||
// same id) does not stomp what the owner is still typing, but forgetting and re-adding does reset it.
|
|
||||||
useEffect(() => {
|
|
||||||
setUrl(connection?.url ?? '');
|
|
||||||
setUsername(connection?.username ?? '');
|
|
||||||
setRpcPath(connection?.path ?? '');
|
|
||||||
setPassword('');
|
|
||||||
setShowAdvanced(!!connection?.username);
|
|
||||||
}, [connection?.id]);
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await save.mutateAsync({ url: url.trim(), username: username.trim(), password, rpcPath: rpcPath.trim() });
|
|
||||||
setPassword('');
|
|
||||||
} catch (err) {
|
|
||||||
setError(serviceErrorMessage(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = async () => {
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await forget.mutateAsync();
|
|
||||||
} catch (err) {
|
|
||||||
setError(serviceErrorMessage(err));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full overflow-y-auto">
|
|
||||||
<div className="mx-auto flex max-w-xl flex-col gap-6 p-6">
|
|
||||||
<header className="flex items-start gap-3">
|
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-amber-500/15 text-amber-500">
|
|
||||||
<Plug className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold">Transmission daemon</h2>
|
|
||||||
<p className={HINT}>
|
|
||||||
{connection
|
|
||||||
? 'Where Officer talks to Transmission. Saving re-checks the daemon before storing anything.'
|
|
||||||
: 'Point Officer at your Transmission daemon. Usually the URL is all it needs.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{connection && <StatusRow connection={connection} health={health} />}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-4">
|
|
||||||
<Field
|
|
||||||
label="Server URL"
|
|
||||||
hint={URL_HINT}
|
|
||||||
value={url}
|
|
||||||
onChange={setUrl}
|
|
||||||
placeholder={DEFAULT_URL}
|
|
||||||
autoFocus={!connection}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAdvanced((v) => !v)}
|
|
||||||
className="self-start text-[11px] font-medium text-primary hover:underline"
|
|
||||||
>
|
|
||||||
{showAdvanced ? 'Hide' : 'Show'} authentication and RPC path
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showAdvanced && (
|
|
||||||
<>
|
|
||||||
<Field label="Username" hint={AUTH_HINT} value={username} onChange={setUsername} placeholder="(none)" />
|
|
||||||
<Field
|
|
||||||
label="Password"
|
|
||||||
value={password}
|
|
||||||
onChange={setPassword}
|
|
||||||
placeholder={connection?.hasSecret ? '•••••••• (unchanged)' : '(none)'}
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="RPC path"
|
|
||||||
hint={PATH_HINT}
|
|
||||||
value={rpcPath}
|
|
||||||
onChange={setRpcPath}
|
|
||||||
placeholder="/transmission/rpc"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="flex items-start gap-2 rounded-lg bg-destructive/10 p-2.5 text-xs text-destructive">
|
|
||||||
<TriangleAlert className="mt-px h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button size="sm" onClick={submit} disabled={!url.trim() || save.isPending}>
|
|
||||||
{save.isPending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
|
|
||||||
{connection ? 'Save' : 'Connect'}
|
|
||||||
</Button>
|
|
||||||
{connection && (
|
|
||||||
<Button size="sm" variant="ghost" onClick={remove} disabled={forget.isPending}>
|
|
||||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
|
||||||
Disconnect
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type StatusRowProps = { connection: ServiceConnection; health: { ok: boolean; error?: string } | undefined };
|
|
||||||
|
|
||||||
const StatusRow = ({ connection, health }: StatusRowProps) => (
|
|
||||||
<div className="flex items-start gap-2 rounded-xl border p-3 text-xs">
|
|
||||||
{health?.ok ? (
|
|
||||||
<CheckCircle2 className="mt-px h-4 w-4 shrink-0 text-emerald-500" />
|
|
||||||
) : (
|
|
||||||
<TriangleAlert className="mt-px h-4 w-4 shrink-0 text-amber-500" />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="font-medium">{health?.ok ? 'Connected' : 'Not responding'}</div>
|
|
||||||
<div className="truncate text-muted-foreground">
|
|
||||||
{connection.url}
|
|
||||||
{connection.version ? ` · Transmission ${connection.version}` : ''}
|
|
||||||
</div>
|
|
||||||
{!health?.ok && health?.error && <div className="text-destructive">{health.error}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
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 the URL
|
|
||||||
under Connection still 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
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,
|
|
||||||
Plug,
|
|
||||||
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,
|
|
||||||
connection: Plug,
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useServiceConnection } from '../../hooks/useServiceConnection';
|
|
||||||
import { useTransmissionSection } from './useTransmissionSection';
|
|
||||||
import { TorrentsView } from './TorrentsView';
|
|
||||||
import { StatsView } from './StatsView';
|
|
||||||
import { SettingsView } from './SettingsView';
|
|
||||||
import { ConnectionView } from './ConnectionView';
|
|
||||||
|
|
||||||
// Right panel of the /transmission workspace — renders the section named by the URL.
|
|
||||||
//
|
|
||||||
// With no daemon configured every other section can only render an error, so the connection form takes over
|
|
||||||
// until there is one. The URL is left alone: once connected, the section already in it is what appears.
|
|
||||||
|
|
||||||
export const TransmissionView = () => {
|
|
||||||
const section = useTransmissionSection();
|
|
||||||
const { data, isLoading } = useServiceConnection('transmission');
|
|
||||||
|
|
||||||
if (!isLoading && !data?.configured) return <ConnectionView />;
|
|
||||||
|
|
||||||
switch (section) {
|
|
||||||
case 'connection':
|
|
||||||
return <ConnectionView />;
|
|
||||||
case 'stats':
|
|
||||||
return <StatsView />;
|
|
||||||
case 'settings':
|
|
||||||
return <SettingsView />;
|
|
||||||
default:
|
|
||||||
return <TorrentsView />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
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';
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import type { Torrent } from '../shared';
|
|
||||||
import { Check, Copy } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
formatDate,
|
|
||||||
formatDuration,
|
|
||||||
formatEta,
|
|
||||||
formatPercent,
|
|
||||||
formatRatio,
|
|
||||||
formatSize,
|
|
||||||
formatSpeed,
|
|
||||||
PRIORITY_LABELS,
|
|
||||||
statusLabel,
|
|
||||||
} from '../format';
|
|
||||||
import { copyToClipboard } from 'helpers/clipboard';
|
|
||||||
|
|
||||||
// The "what is this torrent" tab: transfer numbers, then the immutable facts from the metainfo.
|
|
||||||
|
|
||||||
type GeneralTabProps = { torrent: Torrent };
|
|
||||||
|
|
||||||
export const GeneralTab = ({ torrent: t }: GeneralTabProps) => (
|
|
||||||
<div className="space-y-5 p-4">
|
|
||||||
{t.error !== 0 && t.errorString && (
|
|
||||||
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">{t.errorString}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Section title="Transfer">
|
|
||||||
<Field label="Status" value={statusLabel(t)} />
|
|
||||||
<Field label="Progress" value={formatPercent(t.percentDone)} />
|
|
||||||
<Field label="Downloaded" value={formatSize(t.downloadedEver)} />
|
|
||||||
<Field label="Uploaded" value={formatSize(t.uploadedEver)} />
|
|
||||||
<Field label="Ratio" value={formatRatio(t.uploadRatio)} />
|
|
||||||
<Field label="Remaining" value={formatSize(t.leftUntilDone)} />
|
|
||||||
<Field label="Down speed" value={formatSpeed(t.rateDownload) || '—'} />
|
|
||||||
<Field label="Up speed" value={formatSpeed(t.rateUpload) || '—'} />
|
|
||||||
<Field label="ETA" value={t.percentDone >= 1 ? '—' : formatEta(t.eta)} />
|
|
||||||
<Field label="Seeds / peers" value={`${t.peersSendingToUs} / ${t.peersGettingFromUs}`} />
|
|
||||||
<Field label="Seeding time" value={formatDuration(t.secondsSeeding)} />
|
|
||||||
{/* Corrupt bytes are re-downloaded silently, so this is the only place a failing disk or a bad peer
|
|
||||||
shows up before the ratio quietly goes wrong. */}
|
|
||||||
<Field label="Corrupt" value={t.corruptEver ? formatSize(t.corruptEver) : '—'} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Dates">
|
|
||||||
<Field label="Added" value={formatDate(t.addedDate)} />
|
|
||||||
<Field label="Completed" value={formatDate(t.doneDate)} />
|
|
||||||
<Field label="Last active" value={formatDate(t.activityDate)} />
|
|
||||||
<Field label="Created" value={formatDate(t.dateCreated)} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Torrent">
|
|
||||||
<Field label="Total size" value={formatSize(t.totalSize)} />
|
|
||||||
<Field label="Files" value={String(t['file-count'] ?? '—')} />
|
|
||||||
<Field label="Pieces" value={`${t.pieceCount} × ${formatSize(t.pieceSize)}`} />
|
|
||||||
<Field label="Privacy" value={t.isPrivate ? 'Private torrent' : 'Public torrent'} />
|
|
||||||
<Field label="Priority" value={PRIORITY_LABELS[t.bandwidthPriority] ?? '—'} />
|
|
||||||
<Field label="Queue position" value={String(t.queuePosition)} />
|
|
||||||
<Field label="Creator" value={t.creator || '—'} />
|
|
||||||
<Field label="Peer limit" value={String(t['peer-limit'] ?? '—')} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Location" columns={1}>
|
|
||||||
<Field label="Download directory" value={t.downloadDir} mono />
|
|
||||||
<Field label="Hash" value={t.hashString ?? '—'} mono copy={t.hashString} />
|
|
||||||
<Field label="Magnet" value={t.magnetLink || '—'} mono truncate copy={t.magnetLink} />
|
|
||||||
{t.labels.length > 0 && <Field label="Labels" value={t.labels.join(', ')} />}
|
|
||||||
{t.comment && <Field label="Comment" value={t.comment} />}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{t.peersFrom && (
|
|
||||||
<Section title="Peer sources">
|
|
||||||
<Field label="Tracker" value={String(t.peersFrom.fromTracker)} />
|
|
||||||
<Field label="DHT" value={String(t.peersFrom.fromDht)} />
|
|
||||||
<Field label="PEX" value={String(t.peersFrom.fromPex)} />
|
|
||||||
<Field label="Incoming" value={String(t.peersFrom.fromIncoming)} />
|
|
||||||
<Field label="Local discovery" value={String(t.peersFrom.fromLpd)} />
|
|
||||||
<Field label="Cache" value={String(t.peersFrom.fromCache)} />
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Section = ({ title, children, columns = 2 }: { title: string; children: ReactNode; columns?: number }) => (
|
|
||||||
<section>
|
|
||||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
|
||||||
<dl className={`grid gap-x-6 gap-y-1.5 ${columns === 1 ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}`}>
|
|
||||||
{children}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
|
|
||||||
type FieldProps = { label: string; value: string; mono?: boolean; truncate?: boolean; copy?: string };
|
|
||||||
|
|
||||||
const Field = ({ label, value, mono, truncate, copy }: FieldProps) => (
|
|
||||||
<div className="flex min-w-0 items-baseline gap-3 text-xs">
|
|
||||||
<dt className="w-32 shrink-0 text-muted-foreground">{label}</dt>
|
|
||||||
<dd className={`min-w-0 flex-1 ${mono ? 'font-mono' : ''} ${truncate ? 'truncate' : 'break-words'}`} title={value}>
|
|
||||||
{value}
|
|
||||||
</dd>
|
|
||||||
{copy && <CopyButton value={copy} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const CopyButton = ({ value }: { value: string }) => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => {
|
|
||||||
// copyToClipboard handles the insecure-origin case — navigator.clipboard does not exist over
|
|
||||||
// plain http, which Officer IS reached over on a tailnet. This comment used to say "Officer is
|
|
||||||
// always behind HTTPS"; it was not, and the button silently copied nothing.
|
|
||||||
void copyToClipboard(value).then(
|
|
||||||
() => {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1200);
|
|
||||||
},
|
|
||||||
() => undefined,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
aria-label="Copy"
|
|
||||||
>
|
|
||||||
{copied ? <Check className="h-3 w-3 text-emerald-500" /> : <Copy className="h-3 w-3" />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
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.
@@ -1,127 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
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())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Progress, floored — never rounded. `toFixed` rounds, so 99.97% printed as "100.0%" next to a bar that
|
|
||||||
* is still blue, which reads as a stuck torrent rather than a nearly-finished one. Only an actually
|
|
||||||
* complete fraction may show a hundred, and it shows it as a flat "100%" so the two can't be confused.
|
|
||||||
*/
|
|
||||||
export function formatPercent(fraction: number | undefined | null): string {
|
|
||||||
if (fraction == null) return '—';
|
|
||||||
if (fraction >= 1) return '100%';
|
|
||||||
return `${(Math.floor(fraction * 1000) / 10).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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
// 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' },
|
|
||||||
{ id: 'connection', label: 'Connection' },
|
|
||||||
] 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;
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
import type { SessionSettings, SessionStats, Torrent, TorrentAction } from './shared';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
|
|
||||||
// Data layer for the /transmission panels. Everything talks to the officer-transmission sidecar through the
|
|
||||||
// /api/transmission auth proxy.
|
|
||||||
//
|
|
||||||
// Transmission has no push channel — it is poll-only RPC — so the list refetches on an interval rather than
|
|
||||||
// subscribing. Mutations invalidate the whole ['transmission'] prefix instead of patching caches: starting
|
|
||||||
// one torrent changes the session speed totals and the queue positions of every torrent below it, so a
|
|
||||||
// targeted patch would be wrong more often than it was cheap.
|
|
||||||
|
|
||||||
const TORRENTS_KEY = ['transmission', 'torrents'] as const;
|
|
||||||
const SESSION_KEY = ['transmission', 'session'] as const;
|
|
||||||
const STATS_KEY = ['transmission', 'stats'] as const;
|
|
||||||
|
|
||||||
const EMPTY_TORRENTS: Torrent[] = [];
|
|
||||||
|
|
||||||
/** How often the list re-reads. Matches the reference client's default and is cheap against loopback. */
|
|
||||||
const LIST_POLL_MS = 5_000;
|
|
||||||
/** Session settings change only when someone edits them, so this is a safety net, not a live feed. */
|
|
||||||
const SESSION_POLL_MS = 60_000;
|
|
||||||
|
|
||||||
export function useTorrents() {
|
|
||||||
const { get } = useClient();
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: TORRENTS_KEY,
|
|
||||||
queryFn: () => get<{ torrents: Torrent[] }>('/transmission/_officer/torrents'),
|
|
||||||
refetchInterval: LIST_POLL_MS,
|
|
||||||
// Anything shorter than the poll would make an unrelated remount trigger a second fetch immediately.
|
|
||||||
staleTime: LIST_POLL_MS - 1_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
torrents: query.data?.torrents ?? EMPTY_TORRENTS,
|
|
||||||
isLoading: query.isLoading,
|
|
||||||
error: query.error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One torrent with the expensive arrays (files, peers, trackers). Kept separate from the list because those
|
|
||||||
* arrays are per-file and per-peer — pulling them for every row on every poll is what makes naive
|
|
||||||
* Transmission clients hammer the daemon.
|
|
||||||
*/
|
|
||||||
export function useTorrentDetail(id: number | null) {
|
|
||||||
const { get } = useClient();
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: ['transmission', 'torrent', id] as const,
|
|
||||||
queryFn: () => get<{ torrent: Torrent }>(`/transmission/_officer/torrents/${id}`),
|
|
||||||
enabled: id != null,
|
|
||||||
refetchInterval: LIST_POLL_MS,
|
|
||||||
staleTime: LIST_POLL_MS - 1_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { torrent: query.data?.torrent ?? null, isLoading: query.isLoading, error: query.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSessionStats() {
|
|
||||||
const { get } = useClient();
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: STATS_KEY,
|
|
||||||
queryFn: () => get<{ stats: SessionStats }>('/transmission/_officer/stats'),
|
|
||||||
refetchInterval: LIST_POLL_MS,
|
|
||||||
staleTime: LIST_POLL_MS - 1_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { stats: query.data?.stats ?? null, isLoading: query.isLoading, error: query.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSession() {
|
|
||||||
const { get, post } = useClient();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: SESSION_KEY,
|
|
||||||
queryFn: () => get<{ session: SessionSettings }>('/transmission/_officer/session'),
|
|
||||||
refetchInterval: SESSION_POLL_MS,
|
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const save = useMutation({
|
|
||||||
mutationFn: (patch: Record<string, unknown>) =>
|
|
||||||
post<{ session: SessionSettings }>('/transmission/_officer/session', patch),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
// The sidecar reads the settings back after writing, because Transmission clamps several of them.
|
|
||||||
// Seeding the cache with that response means the form shows what the daemon actually holds.
|
|
||||||
qc.setQueryData(SESSION_KEY, data);
|
|
||||||
toast.success('Settings saved');
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(errorMessage(err, 'Could not save settings')),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
session: query.data?.session ?? null,
|
|
||||||
isLoading: query.isLoading,
|
|
||||||
error: query.error,
|
|
||||||
save,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Free space at a path, as the daemon sees it. Worth stating plainly: this is the *daemon's* filesystem,
|
|
||||||
* which for a containerised Transmission is not the same namespace as the platform's — the number is
|
|
||||||
* meaningful, the path may not resolve to anything on this host.
|
|
||||||
*/
|
|
||||||
export function useFreeSpace(path: string | undefined) {
|
|
||||||
const { get } = useClient();
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: ['transmission', 'free-space', path] as const,
|
|
||||||
queryFn: () =>
|
|
||||||
get<{ path: string; bytes: number }>(`/transmission/_officer/free-space?path=${encodeURIComponent(path!)}`),
|
|
||||||
enabled: !!path,
|
|
||||||
staleTime: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { bytes: query.data?.bytes ?? null, isLoading: query.isLoading, error: query.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AddTorrentInput = {
|
|
||||||
metainfo?: string;
|
|
||||||
filename?: string;
|
|
||||||
downloadDir?: string;
|
|
||||||
labels?: string[];
|
|
||||||
paused?: boolean;
|
|
||||||
bandwidthPriority?: number;
|
|
||||||
sequentialDownload?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AddTorrentResult = { status: 'added' | 'duplicate'; torrent: { id: number; name: string } | null };
|
|
||||||
|
|
||||||
export function useTorrentMutations() {
|
|
||||||
const { post } = useClient();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['transmission'] });
|
|
||||||
|
|
||||||
const act = useMutation({
|
|
||||||
mutationFn: ({ ids, action }: { ids: number[]; action: TorrentAction }) =>
|
|
||||||
post<{ ok: true; affected: number }>('/transmission/_officer/torrents/action', { ids, action }),
|
|
||||||
onSuccess: invalidate,
|
|
||||||
onError: (err) => toast.error(errorMessage(err, 'Action failed')),
|
|
||||||
});
|
|
||||||
|
|
||||||
const set = useMutation({
|
|
||||||
mutationFn: ({ ids, ...fields }: { ids: number[] } & Record<string, unknown>) =>
|
|
||||||
post<{ ok: true }>('/transmission/_officer/torrents/set', { ids, ...fields }),
|
|
||||||
onSuccess: invalidate,
|
|
||||||
onError: (err) => toast.error(errorMessage(err, 'Could not apply the change')),
|
|
||||||
});
|
|
||||||
|
|
||||||
const remove = useMutation({
|
|
||||||
mutationFn: ({ ids, deleteLocalData }: { ids: number[]; deleteLocalData: boolean }) =>
|
|
||||||
post<{ ok: true; affected: number }>('/transmission/_officer/torrents/remove', { ids, deleteLocalData }),
|
|
||||||
onSuccess: (data, vars) => {
|
|
||||||
invalidate();
|
|
||||||
toast.success(
|
|
||||||
`Removed ${vars.ids.length} torrent${vars.ids.length === 1 ? '' : 's'}${
|
|
||||||
vars.deleteLocalData ? ' and their data' : ''
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(errorMessage(err, 'Could not remove')),
|
|
||||||
});
|
|
||||||
|
|
||||||
const setLocation = useMutation({
|
|
||||||
mutationFn: ({ ids, location, move }: { ids: number[]; location: string; move: boolean }) =>
|
|
||||||
post<{ ok: true }>('/transmission/_officer/torrents/location', { ids, location, move }),
|
|
||||||
onSuccess: () => {
|
|
||||||
invalidate();
|
|
||||||
toast.success('Location updated');
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(errorMessage(err, 'Could not change location')),
|
|
||||||
});
|
|
||||||
|
|
||||||
const rename = useMutation({
|
|
||||||
mutationFn: ({ id, path, name }: { id: number; path: string; name: string }) =>
|
|
||||||
post<{ ok: true }>('/transmission/_officer/torrents/rename', { id, path, name }),
|
|
||||||
onSuccess: () => {
|
|
||||||
invalidate();
|
|
||||||
toast.success('Renamed');
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(errorMessage(err, 'Could not rename')),
|
|
||||||
});
|
|
||||||
|
|
||||||
const add = useMutation({
|
|
||||||
mutationFn: (input: AddTorrentInput) => post<AddTorrentResult>('/transmission/_officer/torrents/add', input),
|
|
||||||
onSuccess: invalidate,
|
|
||||||
onError: (err) => toast.error(errorMessage(err, 'Could not add torrent')),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { act, set, remove, setLocation, rename, add };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMaintenance() {
|
|
||||||
const { get, post } = useClient();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
|
|
||||||
const portTest = useMutation({
|
|
||||||
// GET — it asks the daemon a question and changes nothing on either side. Still a useMutation
|
|
||||||
// because it is fired by a button rather than rendered from cache: that is a UI concern, not an
|
|
||||||
// HTTP one. blocklistUpdate below stays POST, because it really does refetch and replace the list.
|
|
||||||
mutationFn: () => get<{ open: boolean }>('/transmission/_officer/port-test'),
|
|
||||||
onSuccess: (data) => (data.open ? toast.success('Peer port is open') : toast.error('Peer port is closed')),
|
|
||||||
onError: (err) => toast.error(errorMessage(err, 'Port test failed')),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateBlocklist = useMutation({
|
|
||||||
mutationFn: () => post<{ size: number }>('/transmission/_officer/blocklist-update'),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
qc.invalidateQueries({ queryKey: SESSION_KEY });
|
|
||||||
toast.success(`Blocklist updated — ${data.size.toLocaleString()} rules`);
|
|
||||||
},
|
|
||||||
onError: (err) => toast.error(errorMessage(err, 'Blocklist update failed')),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { portTest, updateBlocklist };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useClient rejects with `{status, message}` rather than an Error, and the sidecar's message is a JSON body.
|
|
||||||
* Unwrap both so the toast says "transmission upstream unreachable" instead of "[object Object]".
|
|
||||||
*/
|
|
||||||
function errorMessage(err: unknown, fallback: string): string {
|
|
||||||
const raw = typeof err === 'object' && err !== null && 'message' in err ? String(err.message) : '';
|
|
||||||
if (!raw) return fallback;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as { error?: string };
|
|
||||||
if (parsed.error) return parsed.error;
|
|
||||||
} catch {
|
|
||||||
/* not JSON — use it as-is */
|
|
||||||
}
|
|
||||||
return raw || fallback;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -61,14 +61,6 @@ export type {
|
|||||||
CommandHqCostSummary,
|
CommandHqCostSummary,
|
||||||
} from './apps/CommandHQ';
|
} from './apps/CommandHQ';
|
||||||
|
|
||||||
// Same for /transmission.
|
|
||||||
export {
|
|
||||||
DEFAULT_TRANSMISSION_SECTION,
|
|
||||||
transmissionSectionPath,
|
|
||||||
isTransmissionSection,
|
|
||||||
} from './apps/Transmission/shared';
|
|
||||||
export type { TransmissionSectionId } from './apps/Transmission/shared';
|
|
||||||
|
|
||||||
// Same for /gitea.
|
// Same for /gitea.
|
||||||
export {
|
export {
|
||||||
DEFAULT_GITEA_REPO_TAB,
|
DEFAULT_GITEA_REPO_TAB,
|
||||||
|
|||||||
Reference in New Issue
Block a user