transmission, extracted from the platform into its own repository
The sixth plugin out, immediately after photos. A sidecar, a proxy router and 29 web files; no db/ directory, because there are no tables. ── The one thing that is not self-contained ── The connection lives in `service_connections`, a CORE table, reached through getServiceConnection/saveServiceConnection/getServiceCredentials. This plugin declares no schema. Deliberate: that table is the app store's shared credential store, keyed on (user_id, service), already encrypted at rest and already read by app-store/effects.ts. A private copy of the same three columns would be a second place the same fact could live, and the runbook is explicit that a move plus a data-format change gives any failure two causes. The cost, stated rather than hidden: this plugin depends on a core facility it does not own, and would break if that table changed shape. Photos, by contrast, owns photos_config outright. ── 38 palette classes paid down, mapped by meaning ── blue downloading / download speed -> info cyan checking (verifying) -> busy emerald seeding / complete -> success amber queued / stalled / warnings -> warning Collapsing blue and cyan into one tone would have lost a real distinction in the status dot, so `checking` went to `busy` — in-progress work, which is what that tone is for. ── A near-miss worth recording ── The first copy globbed *.tsx and *.ts and silently missed detail/ and dialogs/ — ten components. tsgo caught it as ten "cannot find module" errors. Worth stating because it is quiet in the other direction: had those been imported lazily, the typecheck would have passed and the panels would have broken at runtime. Check the directory tree, not the file glob. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+158
@@ -0,0 +1,158 @@
|
||||
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-info', bg: 'bg-info/15', dot: 'bg-info' },
|
||||
seeding: { text: 'text-success', bg: 'bg-success/15', dot: 'bg-success' },
|
||||
checking: { text: 'text-busy-ink', bg: 'bg-busy/15', dot: 'bg-busy' },
|
||||
queued: { text: 'text-warning', bg: 'bg-warning/15', dot: 'bg-warning' },
|
||||
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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user