const BASE = "/api"; export interface TorrentState { infoHashHex: string; name: string; trackerUrl: string; uploaded: number; downloaded: number; left: number; status: "idle" | "running" | "paused" | "stopping" | "stopped" | "error"; lastAnnounce: string | null; lastInterval: number; seeders: number; leechers: number; peers: number; error: string | null; addedAt: string; } export interface GlobalStats { activeSeeders: number; totalTorrents: number; totalUploaded: number; uptimeSeconds: number; startedAt: string; } /** Extract a human-readable error message from a failed response */ async function extractError(res: Response): Promise { try { const body = await res.json(); if (body && typeof body === "object" && "error" in body) { return typeof body.error === "string" ? body.error : JSON.stringify(body.error); } } catch { // response wasn't JSON } return `Request failed (${res.status})`; } export async function listTorrents(): Promise { const res = await fetch(`${BASE}/torrents`); if (!res.ok) throw new Error(await extractError(res)); return res.json(); } export async function addTorrent(file: File): Promise { const form = new FormData(); form.append("torrent", file); const res = await fetch(`${BASE}/torrents`, { method: "POST", body: form }); if (!res.ok) throw new Error(await extractError(res)); return res.json(); } export async function removeTorrent(hash: string): Promise { const res = await fetch(`${BASE}/torrents/${hash}`, { method: "DELETE" }); if (!res.ok) throw new Error(await extractError(res)); } export async function pauseTorrent(hash: string): Promise { const res = await fetch(`${BASE}/torrents/${hash}/pause`, { method: "POST" }); if (!res.ok) throw new Error(await extractError(res)); } export async function resumeTorrent(hash: string): Promise { const res = await fetch(`${BASE}/torrents/${hash}/resume`, { method: "POST" }); if (!res.ok) throw new Error(await extractError(res)); } export interface AppConfig { port: number; announcePort: number; minUploadRateBytesPerSec: number; maxUploadRateBytesPerSec: number; clientProfile: "qbittorrent" | "transmission"; torrentsDir: string; autoLoad: boolean; } export async function getConfig(): Promise { const res = await fetch(`${BASE}/config`); if (!res.ok) throw new Error(await extractError(res)); return res.json(); } export async function updateConfig(patch: Partial>): Promise { const res = await fetch(`${BASE}/config`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (!res.ok) throw new Error(await extractError(res)); return res.json(); } export async function getStats(): Promise { const res = await fetch(`${BASE}/status`); if (!res.ok) throw new Error(await extractError(res)); return res.json(); } export function connectSSE( onTorrent: (state: TorrentState) => void, onStats: (stats: GlobalStats) => void, onTorrents: (torrents: TorrentState[]) => void, ): EventSource { const es = new EventSource(`${BASE}/status/stream`); es.addEventListener("torrent", (e) => { try { onTorrent(JSON.parse(e.data)); } catch { /* malformed SSE data */ } }); es.addEventListener("stats", (e) => { try { onStats(JSON.parse(e.data)); } catch { /* malformed SSE data */ } }); es.addEventListener("torrents", (e) => { try { onTorrents(JSON.parse(e.data)); } catch { /* malformed SSE data */ } }); return es; } export function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; return `${(bytes / 1024 ** 3).toFixed(2)} GB`; } export function formatUptime(seconds: number): string { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; return `${h}h ${m}m ${s}s`; }