/**
* 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;