feat: terminal-native torrent discovery TUI

Curated, multi-source torrent search and download from the terminal:
concurrent search across built-in sources with streamed, source-tagged
results; a download-only webtorrent engine with true pause/resume and
restart recovery; magnet paste; and a calm Ink + React TUI. No indexer
server, no setup.
This commit is contained in:
bairon.dev
2026-06-27 02:03:19 -04:00
commit 60d7a9a755
71 changed files with 11293 additions and 0 deletions
+256
View File
@@ -0,0 +1,256 @@
export interface AnsiToSvgOptions {
cols: number;
bg?: string;
title?: string;
maxWidth?: number;
}
interface Style {
bold: boolean;
dim: boolean;
italic: boolean;
inverse: boolean;
fg: string | null;
bg: string | null;
}
interface Run {
col: number;
text: string;
style: Style;
}
const FONT_SIZE = 16;
const CHAR_W = 9.6;
const LINE_H = 22;
const PAD = 32;
const HEADER_H = 36;
const RADIUS = 14;
const BG = "#0c0b14";
const FG_DEFAULT = "#ddd8ea";
const DIM_OPACITY = 0.55;
const FONT_STACK =
'ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace';
const PALETTE16 = [
"#262626", "#ee7d92", "#86d6a2", "#f0c560",
"#8fb4d8", "#c79bd8", "#7fc8c4", "#ece4da",
"#7f7f7f", "#f59cab", "#a3e6bb", "#f7d68a",
"#a9c8e8", "#d8b3e8", "#9adcd8", "#fff8ef",
];
function freshStyle(): Style {
return { bold: false, dim: false, italic: false, inverse: false, fg: null, bg: null };
}
function hex(n: number): string {
return n.toString(16).padStart(2, "0");
}
function color256(n: number): string {
if (n < 16) return PALETTE16[n]!;
if (n < 232) {
const v = (x: number) => (x === 0 ? 0 : 55 + x * 40);
const i = n - 16;
const r = v(Math.floor(i / 36));
const g = v(Math.floor(i / 6) % 6);
const b = v(i % 6);
return `#${hex(r)}${hex(g)}${hex(b)}`;
}
const gray = 8 + 10 * (n - 232);
return `#${hex(gray)}${hex(gray)}${hex(gray)}`;
}
function applySgr(style: Style, params: number[]): Style {
const s = { ...style };
if (params.length === 0) params = [0];
for (let i = 0; i < params.length; i++) {
const p = params[i]!;
if (p === 0) Object.assign(s, freshStyle());
else if (p === 1) s.bold = true;
else if (p === 2) s.dim = true;
else if (p === 22) { s.bold = false; s.dim = false; }
else if (p === 3) s.italic = true;
else if (p === 23) s.italic = false;
else if (p === 7) s.inverse = true;
else if (p === 27) s.inverse = false;
else if (p === 39) s.fg = null;
else if (p === 49) s.bg = null;
else if (p === 38 || p === 48) {
const isFg = p === 38;
const mode = params[i + 1];
if (mode === 2) {
const [r, g, b] = [params[i + 2] ?? 0, params[i + 3] ?? 0, params[i + 4] ?? 0];
const c = `#${hex(r)}${hex(g)}${hex(b)}`;
if (isFg) s.fg = c; else s.bg = c;
i += 4;
} else if (mode === 5) {
const c = color256(params[i + 2] ?? 0);
if (isFg) s.fg = c; else s.bg = c;
i += 2;
}
} else if (p >= 30 && p <= 37) s.fg = PALETTE16[p - 30]!;
else if (p >= 90 && p <= 97) s.fg = PALETTE16[p - 90 + 8]!;
else if (p >= 40 && p <= 47) s.bg = PALETTE16[p - 40]!;
else if (p >= 100 && p <= 107) s.bg = PALETTE16[p - 100 + 8]!;
}
return s;
}
function sameStyle(a: Style, b: Style): boolean {
return (
a.bold === b.bold &&
a.dim === b.dim &&
a.italic === b.italic &&
a.inverse === b.inverse &&
a.fg === b.fg &&
a.bg === b.bg
);
}
function parseLine(line: string, state: { style: Style }, cols: number): Run[] {
const cleaned = line.replace(/\x1b\[[0-9;?]*[A-HJKSTfhilsu]/g, "");
const parts = cleaned.split(/(\x1b\[[0-9;]*m)/);
const runs: Run[] = [];
let col = 0;
for (const part of parts) {
if (part === "") continue;
const m = part.match(/^\x1b\[([0-9;]*)m$/);
if (m) {
const params = m[1] === "" ? [] : m[1]!.split(";").map(Number);
state.style = applySgr(state.style, params);
continue;
}
const text = Array.from(part);
if (text.length === 0) continue;
const last = runs[runs.length - 1];
if (last && sameStyle(last.style, state.style) && last.col + Array.from(last.text).length === col) {
last.text += part;
} else {
runs.push({ col, text: part, style: { ...state.style } });
}
col += text.length;
}
if (col < cols) {
runs.push({ col, text: " ".repeat(cols - col), style: freshStyle() });
}
return runs;
}
function escapeXml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
const fmt = (n: number): string => String(Math.round(n * 100) / 100);
export function ansiToSvg(frame: string, opts: AnsiToSvgOptions): string {
const { cols, bg = BG, title, maxWidth } = opts;
const lines = frame.replace(/\r/g, "").split("\n");
const isBlank = (l: string): boolean =>
l.replace(/\x1b\[[0-9;]*m/g, "").trim() === "";
while (lines.length > 0 && isBlank(lines[0]!)) lines.shift();
while (lines.length > 0 && isBlank(lines[lines.length - 1]!)) lines.pop();
const width = cols * CHAR_W + PAD * 2;
const height = HEADER_H + lines.length * LINE_H + PAD * 2;
const cap = maxWidth ?? width;
const out: string[] = [];
out.push(`<?xml version="1.0" encoding="UTF-8"?>`);
out.push(
`<svg xmlns="http://www.w3.org/2000/svg" width="${fmt(width)}" height="${fmt(height)}" viewBox="0 0 ${fmt(width)} ${fmt(height)}" style="max-width: ${fmt(cap)}px; width: 100%; height: auto;" role="img">`,
);
out.push(` <rect width="100%" height="100%" rx="${RADIUS}" fill="${bg}"/>`);
out.push(
` <rect x="0.5" y="0.5" width="${fmt(width - 1)}" height="${fmt(height - 1)}" rx="${RADIUS}" fill="none" stroke="#272430" stroke-width="1"/>`,
);
if (title) {
out.push(
` <text x="${fmt(width / 2)}" y="${fmt(HEADER_H / 2 + 5)}" text-anchor="middle" font-family='${FONT_STACK}' font-size="13" fill="#8a8a8a">${escapeXml(title)}</text>`,
);
}
out.push(
` <g font-family='${FONT_STACK}' font-size="${FONT_SIZE}" fill="${FG_DEFAULT}">`,
);
const state = { style: freshStyle() };
lines.forEach((line, row) => {
const runs = parseLine(line, state, cols);
const baseline = HEADER_H + PAD + row * LINE_H + FONT_SIZE;
for (const run of runs) {
let { col, text } = run;
const st = run.style;
const cells = Array.from(text);
const paintsBox = st.inverse || st.bg !== null;
if (!paintsBox) {
let start = 0;
let end = cells.length;
while (start < end && cells[start] === " ") start++;
while (end > start && cells[end - 1] === " ") end--;
if (start === end) continue;
col += start;
text = cells.slice(start, end).join("");
}
const n = Array.from(text).length;
const x = PAD + col * CHAR_W;
const w = n * CHAR_W;
const fg = st.fg ?? FG_DEFAULT;
if (/^[│┃]+$/.test(text)) {
const barW = text[0] === "┃" ? 2.8 : 1.4;
for (let k = 0; k < n; k++) {
const cx = PAD + (col + k) * CHAR_W + (CHAR_W - barW) / 2;
out.push(
` <rect x="${fmt(cx)}" y="${fmt(baseline - FONT_SIZE)}" width="${fmt(barW)}" height="${LINE_H}" fill="${fg}"${st.dim ? ` fill-opacity="${DIM_OPACITY}"` : ""}/>`,
);
}
continue;
}
if (/^[█▀▄▌▐]+$/.test(text)) {
const top = baseline - FONT_SIZE;
const half = LINE_H / 2;
const dim = st.dim ? ` fill-opacity="${DIM_OPACITY}"` : "";
const bcells = Array.from(text);
for (let k = 0; k < n; k++) {
const ch = bcells[k]!;
const cellLeft = PAD + (col + k) * CHAR_W;
let rx = cellLeft;
let ry = top;
let rw = CHAR_W;
let rh = LINE_H;
if (ch === "▀") rh = half;
else if (ch === "▄") { ry = top + half; rh = half; }
else if (ch === "▌") rw = CHAR_W / 2;
else if (ch === "▐") { rx = cellLeft + CHAR_W / 2; rw = CHAR_W / 2; }
out.push(
` <rect x="${fmt(rx)}" y="${fmt(ry)}" width="${fmt(rw)}" height="${fmt(rh)}" fill="${fg}"${dim}/>`,
);
}
continue;
}
const boxFill = st.inverse ? fg : st.bg;
if (boxFill) {
out.push(
` <rect x="${fmt(x)}" y="${fmt(baseline - FONT_SIZE)}" width="${fmt(w)}" height="${LINE_H}" fill="${boxFill}"${st.dim ? ` fill-opacity="${DIM_OPACITY}"` : ""}/>`,
);
}
if (text.trim() === "" && !st.inverse) continue;
const attrs: string[] = [
`x="${fmt(x)}"`,
`y="${fmt(baseline)}"`,
`textLength="${fmt(w)}"`,
`lengthAdjust="spacingAndGlyphs"`,
`xml:space="preserve"`,
];
const fill = st.inverse ? bg : fg;
if (fill !== FG_DEFAULT) attrs.push(`fill="${fill}"`);
if (st.dim && !st.inverse) attrs.push(`fill-opacity="${DIM_OPACITY}"`);
if (st.bold) attrs.push(`font-weight="600"`);
if (st.italic) attrs.push(`font-style="italic"`);
out.push(` <text ${attrs.join(" ")}>${escapeXml(text)}</text>`);
}
});
out.push(" </g>");
out.push("</svg>");
return out.join("\n");
}
+240
View File
@@ -0,0 +1,240 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import React from "react";
import { render } from "ink-testing-library";
import { Box, Text } from "ink";
import { StoreContext, type Store } from "../src/ui/store";
import { COLOR, ICON, SOURCE_STYLE } from "../src/ui/theme";
import { Logo } from "../src/ui/components/Logo";
import { Rule } from "../src/ui/components/Rule";
import { Footer } from "../src/ui/components/Footer";
import { Sidebar, RAIL_WIDTH } from "../src/ui/components/Sidebar";
import { SearchBar } from "../src/ui/components/SearchBar";
import { Panel } from "../src/ui/components/Panel";
import { Downloads } from "../src/ui/components/Downloads";
import { footerHints } from "../src/ui/keymap";
import { sourcesByGroup } from "../src/sources/registry";
import { cleanText, formatBytes, formatRelative } from "../src/util/format";
import { ansiToSvg } from "./ansi-to-svg";
import type { Config } from "../src/config/config";
import type { DownloadQueue } from "../src/download/queue";
import type { QueueItem } from "../src/download/types";
import type { HistoryItem } from "../src/download/history";
import type { TorrentResult } from "../src/sources/types";
const COLS = 80;
const CONTENT_WIDTH = Math.max(24, COLS - RAIL_WIDTH - 3);
const RULE_WIDTH = Math.max(10, COLS - 2);
const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "preview");
mkdirSync(OUT_DIR, { recursive: true });
const NOW = Math.floor(Date.now() / 1000);
const NOW_MS = Date.now();
const RESULTS: TorrentResult[] = [
{ infoHash: "b2", name: "Oppenheimer (2023) [1080p WEB]", source: "yts", sizeBytes: 2.1e9, seeders: 1240, leechers: 88, magnet: "", added: NOW - 7200 },
{ infoHash: "g7", name: "Dune: Part Two (2024) [2160p BluRay]", source: "yts", sizeBytes: 8.4e9, seeders: 910, leechers: 41, magnet: "", added: NOW - 90000 },
{ infoHash: "c3", name: "Breaking Bad S05E14 1080p WEB-DL", source: "eztv", sizeBytes: 1.6e9, seeders: 540, leechers: 31, magnet: "", added: NOW - 1800 },
{ infoHash: "e5", name: "[Erai-raws] Jujutsu Kaisen S2 - 23 [1080p]", source: "nyaa", sizeBytes: 1.3e9, seeders: 320, leechers: 12, magnet: "", added: NOW - 900 },
{ infoHash: "d4", name: "Frieren - 28 [1080p]", source: "subsplease", sizeBytes: 1.4e9, seeders: 0, leechers: 0, magnet: "", added: NOW - 600 },
{ infoHash: "a1", name: "Elden Ring: Shadow of the Erdtree Edition", source: "fitgirl", sizeBytes: 0, seeders: 0, leechers: 0, magnet: "", added: NOW - 3600 },
];
const DOWNLOADS: QueueItem[] = [
{ id: "x1", name: "Dune: Part Two (2024) [2160p BluRay]", source: "yts", magnet: "", dir: "", status: "downloading", progress: 64, totalBytes: 8.4e9, downloadedBytes: 5.4e9, speed: 8.1e6, peers: 41, eta: 360, addedAt: NOW_MS },
];
const HISTORY: HistoryItem[] = [
{ id: "h1", name: "Elden Ring: Shadow of the Erdtree Edition", source: "fitgirl", sizeBytes: 54e9, magnet: "", dir: "", completedAt: NOW_MS - 3_600_000 },
{ id: "h2", name: "Breaking Bad S05E14 1080p WEB-DL", source: "eztv", sizeBytes: 1.6e9, magnet: "", dir: "", completedAt: NOW_MS - 90_000_000 },
];
function fakeQueue(items: QueueItem[], history: HistoryItem[]): DownloadQueue {
const active = items.filter((i) => i.status === "downloading").length;
const stub = {
getItems: () => items,
getHistory: () => history,
activeCount: active,
on: () => stub,
off: () => stub,
};
return stub as unknown as DownloadQueue;
}
function makeStore(
overrides: Partial<Store> = {},
items: QueueItem[] = [],
history: HistoryItem[] = [],
): Store {
const noop = (): void => {};
return {
config: { downloadDir: "~/Downloads/torlink" } as Config,
setConfig: noop,
queue: fakeQueue(items, history),
view: "browser",
setView: noop,
query: "",
submitQuery: noop,
section: "all",
setSection: noop,
region: "content",
setRegion: noop,
captureMode: "none",
setCaptureMode: noop,
downloadFocus: null,
setDownloadFocus: noop,
startDownload: noop,
notice: null,
setNotice: noop,
quitAll: noop,
listRows: 14,
compact: false,
contentWidth: CONTENT_WIDTH,
cols: COLS,
rows: 24,
...overrides,
};
}
function save(name: string, store: Store, node: React.ReactNode): void {
const { lastFrame, unmount } = render(
<StoreContext.Provider value={store}>{node}</StoreContext.Provider>,
);
const frame = lastFrame() ?? "";
unmount();
if (!/\x1b\[/.test(frame)) {
throw new Error(`${name}: frame has no ANSI colors (FORCE_COLOR didn't take)`);
}
writeFileSync(join(OUT_DIR, `${name}.svg`), ansiToSvg(frame, { cols: COLS, title: "torlink" }));
console.log(`preview/${name}.svg`);
}
const CATEGORIES = sourcesByGroup()
.map((g) => g.group.toLowerCase())
.join(` ${ICON.dot} `);
save(
"splash",
makeStore({ view: "splash", region: "content" }),
<Box height={18} flexDirection="column" justifyContent="center" alignItems="center" width={COLS}>
<Logo />
<Box marginTop={2}>
<Text color={COLOR.text}>A curated, terminal-native torrent downloader.</Text>
</Box>
<Box>
<Text dimColor>{CATEGORIES}</Text>
</Box>
<Box marginTop={1} width={62}>
<SearchBar width={62} value="" editing placeholder="Search, or paste a magnet…" onSubmit={() => {}} />
</Box>
<Box marginTop={1}>
<Text>
<Text color={COLOR.alt}></Text>
<Text dimColor> search</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text dimColor>empty </Text>
<Text color={COLOR.alt}></Text>
<Text dimColor> browse</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.alt}>^c</Text>
<Text dimColor> quit</Text>
</Text>
</Box>
</Box>,
);
const browseResults = RESULTS.slice(0, 5);
const showStats = browseResults.some((r) => r.sizeBytes > 0 || r.seeders > 0);
const numW = Math.max(2, String(browseResults.length).length);
save(
"browse",
makeStore({ section: "all", contentWidth: CONTENT_WIDTH, listRows: 14, cols: COLS, rows: 24 }),
<Box flexDirection="column" width={COLS} paddingX={1}>
<Box justifyContent="space-between">
<Logo />
</Box>
<Rule width={RULE_WIDTH} />
<Box height={14} marginTop={1}>
<Sidebar />
<Box flexGrow={1} flexDirection="column">
<SearchBar width={CONTENT_WIDTH} value="" editing={false} placeholder="Search, or paste a magnet…" onSubmit={() => {}} />
<Box marginTop={1}>
<Panel title="latest" width={CONTENT_WIDTH} focused count={`(${browseResults.length})`} height={9}>
<Box><Text dimColor>newest across all sources</Text></Box>
<Box flexDirection="column" marginTop={1}>
<Box>
<Box width={2} flexShrink={0} />
<Box width={numW} flexShrink={0} justifyContent="flex-end"><Text bold dimColor>#</Text></Box>
<Box flexGrow={1} minWidth={0} marginLeft={1}><Text bold dimColor>Name</Text></Box>
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end"><Text bold dimColor>Size</Text></Box>
<Box width={9} flexShrink={0} marginLeft={1} justifyContent="flex-end"><Text bold dimColor>Seed:Lch</Text></Box>
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end"><Text bold dimColor>Src</Text></Box>
</Box>
{browseResults.map((r, i) => {
const here = i === 0;
const ss = SOURCE_STYLE[r.source];
return (
<Box key={r.infoHash}>
<Box width={2} flexShrink={0}>
<Text color={COLOR.accent}>{here ? ICON.pointer : ""}</Text>
</Box>
<Box width={numW} flexShrink={0} justifyContent="flex-end">
<Text dimColor>{i + 1}</Text>
</Box>
<Box flexGrow={1} minWidth={0} marginLeft={1}>
<Text wrap="truncate-end" color={here ? COLOR.accent : undefined} dimColor={!here} bold={here}>
{cleanText(r.name)}
</Text>
</Box>
{showStats ? (
<>
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text dimColor>{r.sizeBytes > 0 ? formatBytes(r.sizeBytes) : "-"}</Text>
</Box>
<Box width={9} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text color={r.seeders > 0 ? COLOR.good : undefined} dimColor={r.seeders === 0}>
{r.seeders || r.leechers ? `${r.seeders}:${r.leechers}` : "-"}
</Text>
</Box>
</>
) : (
<Box width={9} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text dimColor>{formatRelative(r.added) || "-"}</Text>
</Box>
)}
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text color={ss.color} dimColor={!here}>
{ss.tag}
</Text>
</Box>
</Box>
);
})}
</Box>
</Panel>
</Box>
</Box>
</Box>
<Footer hints={footerHints("content", "all")} />
</Box>,
);
save(
"downloads",
makeStore({ section: "downloads", contentWidth: CONTENT_WIDTH, listRows: 10, cols: COLS, rows: 24 }, DOWNLOADS, HISTORY),
<Box flexDirection="column" width={COLS} paddingX={1}>
<Box justifyContent="space-between">
<Logo />
</Box>
<Rule width={RULE_WIDTH} />
<Box height={10} marginTop={1}>
<Sidebar />
<Box flexGrow={1} flexDirection="column">
<Downloads />
</Box>
</Box>
<Footer hints={footerHints("content", "downloads")} />
</Box>,
);
+4
View File
@@ -0,0 +1,4 @@
process.env.FORCE_COLOR = "3";
await import("./render-previews-impl");
export {};