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 = { 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 = { [-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 = { 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 ); }