/** * Info command for Torm CLI. * * Shows detailed information about a specific torrent. * * @module cli/commands/info */ import React, { useEffect, useState } from 'react'; import { render, Text, Box } from 'ink'; import { TormEngine } from '../../engine/TormEngine.js'; import { Torrent, TorrentState, TrackerStatus } from '../../engine/types.js'; import { formatBytes, formatSpeed, formatProgress, formatEta, truncateText, formatKeyValue, errorMessage, parseTorrentId, ansiColors, colorize, } from '../utils/output.js'; // ============================================================================= // Types // ============================================================================= export interface InfoCommandOptions { /** Torrent ID (info hash or prefix) */ torrentId: string; /** Show file list */ showFiles?: boolean; /** Show tracker list */ showTrackers?: boolean; } interface InfoResultProps { torrent: Torrent | null; error: string | null; loading: boolean; showFiles: boolean; showTrackers: boolean; } // ============================================================================= // Helper Functions // ============================================================================= /** * Map torrent state to Ink color */ function getStateColor(state: TorrentState): string { switch (state) { case TorrentState.DOWNLOADING: return 'blue'; case TorrentState.SEEDING: return 'green'; case TorrentState.PAUSED: return 'yellow'; case TorrentState.ERROR: return 'red'; case TorrentState.CHECKING: return 'cyan'; case TorrentState.QUEUED: return 'gray'; default: return 'white'; } } /** * Get display name for torrent state */ function getStateName(state: TorrentState): string { switch (state) { case TorrentState.DOWNLOADING: return 'Downloading'; case TorrentState.SEEDING: return 'Seeding'; case TorrentState.PAUSED: return 'Paused'; case TorrentState.ERROR: return 'Error'; case TorrentState.CHECKING: return 'Checking'; case TorrentState.QUEUED: return 'Queued'; default: return 'Unknown'; } } /** * Map tracker status to color */ function getTrackerStatusColor(status: TrackerStatus): string { switch (status) { case TrackerStatus.Working: return 'green'; case TrackerStatus.Announcing: return 'cyan'; case TrackerStatus.Error: return 'red'; case TrackerStatus.Idle: default: return 'gray'; } } /** * Find a torrent by ID (full hash or prefix) */ function findTorrent( engine: TormEngine, torrentId: string ): Torrent | undefined { const { type, value } = parseTorrentId(torrentId); if (type === 'hash') { return engine.getTorrent(value); } // Search by prefix const torrents = engine.getAllTorrents(); const matches = torrents.filter((t) => t.infoHash.startsWith(value)); if (matches.length === 0) { return undefined; } if (matches.length > 1) { throw new Error( `Ambiguous torrent ID "${torrentId}" matches ${matches.length} torrents. ` + 'Please provide a longer prefix.' ); } return matches[0]; } // ============================================================================= // Components // ============================================================================= /** * Section header component */ const SectionHeader: React.FC<{ title: string }> = ({ title }) => ( {title} ); /** * Key-value row component */ const InfoRow: React.FC<{ label: string; value: React.ReactNode; width?: number; }> = ({ label, value, width = 14 }) => ( {label}: {typeof value === 'string' ? {value} : value} ); /** * File list component */ const FileList: React.FC<{ files: Torrent['files'] }> = ({ files }) => ( {files.map((file, index) => ( {(index + 1).toString().padStart(3)}. {truncateText(file.path, 48)} {formatBytes(file.size)} ))} ); /** * Tracker list component */ const TrackerList: React.FC<{ trackers: Torrent['trackers'] }> = ({ trackers, }) => ( {trackers.map((tracker, index) => ( {(index + 1).toString().padStart(3)}. {truncateText(tracker.url, 48)} {' '} {tracker.status} {tracker.peers} peers ))} ); /** * Component to display torrent information */ const InfoResult: React.FC = ({ torrent, error, loading, showFiles, showTrackers, }) => { if (loading) { return ( Loading torrent info... ); } if (error) { return ( [ERROR] {error} ); } if (!torrent) { return ( [ERROR] Torrent not found ); } const stateColor = getStateColor(torrent.state); const stateName = getStateName(torrent.state); return ( {/* Header with name */} {torrent.name} {/* Basic info section */} {stateName}} /> {/* Transfer info section */} {/* Connection info section */} {/* Piece info section */} {/* Error message if applicable */} {torrent.error && ( Error: {torrent.error} )} {/* File list if requested */} {showFiles && } {/* Tracker list if requested */} {showTrackers && } {/* Summary footer */} {torrent.files.length} file(s), {torrent.trackers.length} tracker(s) ); }; // ============================================================================= // Main Info Function // ============================================================================= /** * Execute the info command (non-interactive). * * @param options - Command options */ export async function executeInfo(options: InfoCommandOptions): Promise { const { torrentId, showFiles = true, showTrackers = true } = options; const engine = new TormEngine(); try { await engine.start(); const torrent = findTorrent(engine, torrentId); if (!torrent) { console.error(errorMessage(`Torrent not found: ${torrentId}`)); await engine.stop(); process.exit(1); } // Print torrent info console.log(); console.log(colorize(torrent.name, ansiColors.bold)); console.log(); // General info console.log(colorize('General', ansiColors.cyan)); console.log(formatKeyValue('Hash', torrent.infoHash)); console.log(formatKeyValue('Status', getStateName(torrent.state))); console.log(formatKeyValue('Size', formatBytes(torrent.size))); console.log(formatKeyValue('Progress', formatProgress(torrent.progress))); console.log(); // Transfer info console.log(colorize('Transfer', ansiColors.cyan)); console.log(formatKeyValue('Downloaded', formatBytes(torrent.downloaded))); console.log(formatKeyValue('Uploaded', formatBytes(torrent.uploaded))); console.log( formatKeyValue('Down Speed', formatSpeed(torrent.downloadSpeed)) ); console.log(formatKeyValue('Up Speed', formatSpeed(torrent.uploadSpeed))); console.log(formatKeyValue('ETA', formatEta(torrent.eta))); console.log(); // Connection info console.log(colorize('Connections', ansiColors.cyan)); console.log(formatKeyValue('Peers', torrent.peers.toString())); console.log(formatKeyValue('Seeds', torrent.seeds.toString())); console.log(); // Pieces info console.log(colorize('Pieces', ansiColors.cyan)); console.log(formatKeyValue('Piece Size', formatBytes(torrent.pieceLength))); console.log(formatKeyValue('Piece Count', torrent.pieceCount.toString())); console.log(); // File list if (showFiles && torrent.files.length > 0) { console.log(colorize('Files', ansiColors.cyan)); torrent.files.forEach((file, index) => { console.log( ` ${(index + 1).toString().padStart(3)}. ${file.path} (${formatBytes(file.size)})` ); }); console.log(); } // Tracker list if (showTrackers && torrent.trackers.length > 0) { console.log(colorize('Trackers', ansiColors.cyan)); torrent.trackers.forEach((tracker, index) => { console.log( ` ${(index + 1).toString().padStart(3)}. ${tracker.url} [${tracker.status}]` ); }); console.log(); } await engine.stop(); } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error(errorMessage(message)); if (engine.isRunning()) { await engine.stop(); } process.exit(1); } } /** * Info command component using Ink for rendering */ export function InfoCommand({ torrentId, showFiles = true, showTrackers = true, }: InfoCommandOptions): React.ReactElement { const [torrent, setTorrent] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { const doInfo = async () => { const engine = new TormEngine(); try { await engine.start(); const foundTorrent = findTorrent(engine, torrentId); if (!foundTorrent) { setError(`Torrent not found: ${torrentId}`); } else { setTorrent(foundTorrent); } await engine.stop(); } catch (err) { const message = err instanceof Error ? err.message : String(err); setError(message); if (engine.isRunning()) { await engine.stop(); } } finally { setLoading(false); } }; doInfo(); }, [torrentId]); return ( ); } /** * Run the info command with Ink rendering */ export function runInfo(options: InfoCommandOptions): void { const { waitUntilExit } = render(); waitUntilExit().then(() => { process.exit(0); }); } export default executeInfo;