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
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.DS_Store
+88
View File
@@ -0,0 +1,88 @@
<p align="center">
<img src="preview/splash.svg" alt="torlink, curated torrents straight from your terminal" style="max-width: 832px; width: 100%; height: auto;">
</p>
torlink is a torrent finder that lives in your terminal, with zero setup and nothing to configure. One search checks a short, curated list of reputable sources at once, and whatever you pick downloads straight to your computer. The files are yours, saved to your downloads folder.
## Get started
1. **Install Node.** It's a free tool from [nodejs.org](https://nodejs.org), the one thing torlink runs on.
2. **Open your terminal.**
3. **Run torlink:**
```sh
npx torlink
```
That's it, nothing else to install. Once it's open you can paste a magnet link straight into the search box. You can also start from the command line:
```sh
npx torlink "magnet:?xt=..." # start from a magnet link
npx torlink ./file.torrent # open a .torrent file
npm install -g torlink # install for good, then run: torlink
```
## What it does
- **One search, every source.** Type once and torlink checks them all together, showing each result's size and how many people are sharing it.
- **Curated, not a free-for-all.** A small, vetted set of sources, tagged so you always know where a result came from.
- **Downloads only, never seeds.** Files come to your computer and stop there. torlink never sends them back out.
- **Nothing to configure.** It opens straight to a search box, ready the moment it starts.
## Finding something
Type what you're looking for and press Enter. torlink checks every source at once, and results show up as each one answers, so you're never stuck waiting on a slow one. Each result lists its size and how many people are sharing it, so you can see which one will come down fast. Arrow to the one you want, press `d`, and it saves to your downloads folder. Leave the box empty and press Enter to just see what's new across every source.
<p align="center">
<img src="preview/browse.svg" alt="torlink's browse view: the sidebar, the search bar, and merged results from every source" style="max-width: 832px; width: 100%; height: auto;">
</p>
## Your downloads
Your active downloads and the stuff you've already grabbed share one pane. Whatever's still coming down sits up top with its progress, speed, and time left. When a file finishes it drops into Recently downloaded just below, so the list stays tidy instead of piling up. Anything there is one key from downloading again, and it's all still waiting when you come back, resuming anything that got interrupted.
<p align="center">
<img src="preview/downloads.svg" alt="torlink's Downloads pane: live progress on top, recently downloaded below" style="max-width: 832px; width: 100%; height: auto;">
</p>
## What it searches
A short, hand-picked set instead of a directory of unknowns:
| Category | Sources |
| --- | --- |
| Games | FitGirl |
| Movies | YTS, The Pirate Bay, 1337x |
| TV | EZTV, SolidTorrents, The Pirate Bay, 1337x |
| Anime | Nyaa, SubsPlease |
Games are the only category that can run code, so they come from FitGirl alone, a repacker with a long, trusted track record; everything else is plain video and subtitles. A source being down never stops a search, the others keep going and torlink tells you which one is offline.
## Local Development
To run or work on torlink locally:
1. Clone the repository and open the folder.
2. Install dependencies:
```sh
npm install
```
3. Run the development version:
```sh
npm run dev
```
Or build it and run the bundled version:
```sh
npm run build
npx torlink
```
## Star History
<a href="https://www.star-history.com/?repos=baairon%2Ftorlink&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=baairon/torlink&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=baairon/torlink&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=baairon/torlink&type=date&legend=top-left" />
</picture>
</a>
+6160
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
{
"name": "torlink",
"version": "1.0.0",
"description": "Terminal-native torrent discovery. No browser, no ads, no hunting: search and download torrents from a clean terminal UI.",
"type": "module",
"bin": {
"torlink": "./dist/index.js"
},
"files": [
"dist",
"preview"
],
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "tsx src/index.tsx",
"build": "tsup",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"previews": "tsx scripts/render-previews.tsx",
"prepublishOnly": "npm run build"
},
"keywords": [
"torrent",
"magnet",
"webtorrent",
"tui",
"cli",
"terminal",
"downloader",
"search",
"fitgirl"
],
"author": "bairon",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/baairon/torlink.git"
},
"homepage": "https://github.com/baairon/torlink#readme",
"bugs": {
"url": "https://github.com/baairon/torlink/issues"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"env-paths": "^4.0.0",
"ink": "^7.0.5",
"parse-torrent": "^11.0.21",
"react": "^19.2.7",
"webtorrent": "^2.4.1"
},
"devDependencies": {
"@types/node": "^25.9.2",
"@types/react": "^19.2.17",
"ink-testing-library": "^4.0.0",
"tsup": "^8.5.1",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
}
}
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="832" height="540" viewBox="0 0 832 540" style="max-width: 832px; width: 100%; height: auto;" role="img">
<rect width="100%" height="100%" rx="14" fill="#0c0b14"/>
<rect x="0.5" y="0.5" width="831" height="539" rx="14" fill="none" stroke="#272430" stroke-width="1"/>
<text x="416" y="23" text-anchor="middle" font-family='ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace' font-size="13" fill="#8a8a8a">torlink</text>
<g font-family='ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace' font-size="16" fill="#ddd8ea">
<text x="99.2" y="84" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#5ae87a" font-weight="600">𐓏</text>
<rect x="51.2" y="90" width="9.6" height="11" fill="#faf5ff"/>
<rect x="60.8" y="90" width="9.6" height="22" fill="#f5ecff"/>
<rect x="70.4" y="90" width="9.6" height="11" fill="#f0e2ff"/>
<rect x="89.6" y="90" width="9.6" height="22" fill="#e6cffe"/>
<rect x="99.2" y="90" width="9.6" height="11" fill="#e1c5fe"/>
<rect x="108.8" y="90" width="9.6" height="22" fill="#dcbcfe"/>
<rect x="128" y="90" width="9.6" height="22" fill="#d3b0fe"/>
<rect x="137.6" y="90" width="9.6" height="11" fill="#d0adfd"/>
<rect x="147.2" y="90" width="9.6" height="22" fill="#ccaafd"/>
<rect x="166.4" y="90" width="9.6" height="22" fill="#c4a4fc"/>
<rect x="204.8" y="90" width="9.6" height="22" fill="#b597fb"/>
<rect x="224" y="90" width="9.6" height="22" fill="#ae91fb"/>
<rect x="233.6" y="101" width="9.6" height="11" fill="#aa8efa"/>
<rect x="252.8" y="90" width="9.6" height="22" fill="#a487f7"/>
<rect x="272" y="90" width="9.6" height="22" fill="#9e81f3"/>
<rect x="281.6" y="101" width="9.6" height="11" fill="#9b7ef0"/>
<rect x="291.2" y="90" width="9.6" height="11" fill="#997bee"/>
<rect x="60.8" y="112" width="9.6" height="22" fill="#9375e9"/>
<rect x="89.6" y="112" width="9.6" height="22" fill="#8b6ce2"/>
<rect x="99.2" y="123" width="9.6" height="11" fill="#8869e0"/>
<rect x="108.8" y="112" width="9.6" height="22" fill="#8566de"/>
<rect x="128" y="112" width="9.6" height="22" fill="#8060d9"/>
<rect x="137.6" y="112" width="9.6" height="11" fill="#7d5dd7"/>
<rect x="147.2" y="123" width="9.6" height="11" fill="#7a5bd3"/>
<rect x="166.4" y="112" width="9.6" height="22" fill="#7456c9"/>
<rect x="176" y="123" width="9.6" height="11" fill="#7154c4"/>
<rect x="185.6" y="123" width="9.6" height="11" fill="#6e52c0"/>
<rect x="204.8" y="112" width="9.6" height="22" fill="#684eb6"/>
<rect x="224" y="112" width="9.6" height="22" fill="#6249ac"/>
<rect x="243.2" y="112" width="9.6" height="11" fill="#5b45a2"/>
<rect x="252.8" y="112" width="9.6" height="22" fill="#58439d"/>
<rect x="272" y="112" width="9.6" height="22" fill="#523e94"/>
<rect x="291.2" y="112" width="9.6" height="22" fill="#4c3a8a"/>
<text x="41.6" y="150" textLength="748.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#6b6577">──────────────────────────────────────────────────────────────────────────────</text>
<rect x="41.6" y="178" width="4.8" height="22" fill="#6b6577"/>
<text x="60.8" y="194" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">All</text>
<text x="204.8" y="194" textLength="19.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#6b6577">╭─</text>
<text x="233.6" y="194" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#6b6577" font-weight="600">Search</text>
<text x="300.8" y="194" textLength="489.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#6b6577">──────────────────────────────────────────────────╮</text>
<text x="60.8" y="216" textLength="48" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Games</text>
<rect x="208.9" y="200" width="1.4" height="22" fill="#6b6577"/>
<text x="224" y="216" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa"></text>
<text x="243.2" y="216" textLength="249.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Search, or paste a magnet…</text>
<rect x="784.9" y="200" width="1.4" height="22" fill="#6b6577"/>
<text x="60.8" y="238" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Movies</text>
<text x="204.8" y="238" textLength="585.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#6b6577">╰───────────────────────────────────────────────────────────╯</text>
<text x="60.8" y="260" textLength="19.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">TV</text>
<text x="60.8" y="282" textLength="48" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Anime</text>
<text x="204.8" y="282" textLength="19.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa">╭─</text>
<text x="233.6" y="282" textLength="96" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa" font-weight="600">Latest (5)</text>
<text x="339.2" y="282" textLength="451.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa">──────────────────────────────────────────────╮</text>
<rect x="208.9" y="288" width="1.4" height="22" fill="#a78bfa"/>
<text x="224" y="304" textLength="240" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">newest across all sources</text>
<rect x="784.9" y="288" width="1.4" height="22" fill="#a78bfa"/>
<text x="60.8" y="326" textLength="86.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Downloads</text>
<rect x="208.9" y="310" width="1.4" height="22" fill="#a78bfa"/>
<rect x="784.9" y="310" width="1.4" height="22" fill="#a78bfa"/>
<rect x="208.9" y="332" width="1.4" height="22" fill="#a78bfa"/>
<text x="252.8" y="348" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55" font-weight="600">#</text>
<text x="272" y="348" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55" font-weight="600">Name</text>
<text x="588.8" y="348" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55" font-weight="600">Size</text>
<text x="646.4" y="348" textLength="76.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55" font-weight="600">Seed:Lch</text>
<text x="742.4" y="348" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55" font-weight="600">Src</text>
<rect x="784.9" y="332" width="1.4" height="22" fill="#a78bfa"/>
<rect x="208.9" y="354" width="1.4" height="22" fill="#a78bfa"/>
<text x="224" y="370" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa"></text>
<text x="252.8" y="370" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">1</text>
<text x="272" y="370" textLength="249.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa" font-weight="600">Oppenheimer (2023) [1080p…</text>
<text x="560" y="370" textLength="67.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">1.96 GB</text>
<text x="656" y="370" textLength="67.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#86d6a2">1240:88</text>
<text x="742.4" y="370" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#86d6a2">YTS</text>
<rect x="784.9" y="354" width="1.4" height="22" fill="#a78bfa"/>
<rect x="208.9" y="376" width="1.4" height="22" fill="#a78bfa"/>
<text x="252.8" y="392" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">2</text>
<text x="272" y="392" textLength="249.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Dune: Part Two (2024) [21…</text>
<text x="560" y="392" textLength="67.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">7.82 GB</text>
<text x="665.6" y="392" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#86d6a2">910:41</text>
<text x="742.4" y="392" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#86d6a2" fill-opacity="0.55">YTS</text>
<rect x="784.9" y="376" width="1.4" height="22" fill="#a78bfa"/>
<rect x="208.9" y="398" width="1.4" height="22" fill="#a78bfa"/>
<text x="252.8" y="414" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">3</text>
<text x="272" y="414" textLength="249.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Breaking Bad S05E14 1080p…</text>
<text x="560" y="414" textLength="67.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">1.49 GB</text>
<text x="665.6" y="414" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#86d6a2">540:31</text>
<text x="732.8" y="414" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#f0c560" fill-opacity="0.55">EZTV</text>
<rect x="784.9" y="398" width="1.4" height="22" fill="#a78bfa"/>
<rect x="208.9" y="420" width="1.4" height="22" fill="#a78bfa"/>
<text x="252.8" y="436" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">4</text>
<text x="272" y="436" textLength="249.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">[Erai-raws] Jujutsu Kaise…</text>
<text x="560" y="436" textLength="67.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">1.21 GB</text>
<text x="665.6" y="436" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#86d6a2">320:12</text>
<text x="732.8" y="436" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#d8b4fe" fill-opacity="0.55">NYAA</text>
<rect x="784.9" y="420" width="1.4" height="22" fill="#a78bfa"/>
<rect x="208.9" y="442" width="1.4" height="22" fill="#a78bfa"/>
<text x="252.8" y="458" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">5</text>
<text x="272" y="458" textLength="192" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Frieren - 28 [1080p]</text>
<text x="560" y="458" textLength="67.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">1.30 GB</text>
<text x="713.6" y="458" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">-</text>
<text x="742.4" y="458" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6" fill-opacity="0.55">SUB</text>
<rect x="784.9" y="442" width="1.4" height="22" fill="#a78bfa"/>
<text x="204.8" y="480" textLength="585.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa">╰───────────────────────────────────────────────────────────╯</text>
<text x="41.6" y="502" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">d</text>
<text x="60.8" y="502" textLength="76.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Download</text>
<text x="166.4" y="502" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">/</text>
<text x="185.6" y="502" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Search</text>
<text x="272" y="502" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">m</text>
<text x="291.2" y="502" textLength="115.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Paste magnet</text>
<text x="435.2" y="502" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">tab</text>
<text x="473.6" y="502" textLength="105.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Switch pane</text>
<text x="608" y="502" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">?</text>
<text x="627.2" y="502" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Keys</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

+111
View File
@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="832" height="452" viewBox="0 0 832 452" style="max-width: 832px; width: 100%; height: auto;" role="img">
<rect width="100%" height="100%" rx="14" fill="#0c0b14"/>
<rect x="0.5" y="0.5" width="831" height="451" rx="14" fill="none" stroke="#272430" stroke-width="1"/>
<text x="416" y="23" text-anchor="middle" font-family='ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace' font-size="13" fill="#8a8a8a">torlink</text>
<g font-family='ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace' font-size="16" fill="#ddd8ea">
<text x="99.2" y="84" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#5ae87a" font-weight="600">𐓏</text>
<rect x="51.2" y="90" width="9.6" height="11" fill="#faf5ff"/>
<rect x="60.8" y="90" width="9.6" height="22" fill="#f5ecff"/>
<rect x="70.4" y="90" width="9.6" height="11" fill="#f0e2ff"/>
<rect x="89.6" y="90" width="9.6" height="22" fill="#e6cffe"/>
<rect x="99.2" y="90" width="9.6" height="11" fill="#e1c5fe"/>
<rect x="108.8" y="90" width="9.6" height="22" fill="#dcbcfe"/>
<rect x="128" y="90" width="9.6" height="22" fill="#d3b0fe"/>
<rect x="137.6" y="90" width="9.6" height="11" fill="#d0adfd"/>
<rect x="147.2" y="90" width="9.6" height="22" fill="#ccaafd"/>
<rect x="166.4" y="90" width="9.6" height="22" fill="#c4a4fc"/>
<rect x="204.8" y="90" width="9.6" height="22" fill="#b597fb"/>
<rect x="224" y="90" width="9.6" height="22" fill="#ae91fb"/>
<rect x="233.6" y="101" width="9.6" height="11" fill="#aa8efa"/>
<rect x="252.8" y="90" width="9.6" height="22" fill="#a487f7"/>
<rect x="272" y="90" width="9.6" height="22" fill="#9e81f3"/>
<rect x="281.6" y="101" width="9.6" height="11" fill="#9b7ef0"/>
<rect x="291.2" y="90" width="9.6" height="11" fill="#997bee"/>
<rect x="60.8" y="112" width="9.6" height="22" fill="#9375e9"/>
<rect x="89.6" y="112" width="9.6" height="22" fill="#8b6ce2"/>
<rect x="99.2" y="123" width="9.6" height="11" fill="#8869e0"/>
<rect x="108.8" y="112" width="9.6" height="22" fill="#8566de"/>
<rect x="128" y="112" width="9.6" height="22" fill="#8060d9"/>
<rect x="137.6" y="112" width="9.6" height="11" fill="#7d5dd7"/>
<rect x="147.2" y="123" width="9.6" height="11" fill="#7a5bd3"/>
<rect x="166.4" y="112" width="9.6" height="22" fill="#7456c9"/>
<rect x="176" y="123" width="9.6" height="11" fill="#7154c4"/>
<rect x="185.6" y="123" width="9.6" height="11" fill="#6e52c0"/>
<rect x="204.8" y="112" width="9.6" height="22" fill="#684eb6"/>
<rect x="224" y="112" width="9.6" height="22" fill="#6249ac"/>
<rect x="243.2" y="112" width="9.6" height="11" fill="#5b45a2"/>
<rect x="252.8" y="112" width="9.6" height="22" fill="#58439d"/>
<rect x="272" y="112" width="9.6" height="22" fill="#523e94"/>
<rect x="291.2" y="112" width="9.6" height="22" fill="#4c3a8a"/>
<text x="41.6" y="150" textLength="748.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#6b6577">──────────────────────────────────────────────────────────────────────────────</text>
<text x="60.8" y="194" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">All</text>
<text x="204.8" y="194" textLength="19.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa">╭─</text>
<text x="233.6" y="194" textLength="124.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa" font-weight="600">Downloads (1)</text>
<text x="368" y="194" textLength="422.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa">───────────────────────────────────────────╮</text>
<text x="60.8" y="216" textLength="48" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Games</text>
<rect x="208.9" y="200" width="1.4" height="22" fill="#a78bfa"/>
<text x="224" y="216" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa" font-weight="600"></text>
<text x="243.2" y="216" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa"></text>
<text x="262.4" y="216" textLength="345.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa" font-weight="600">Dune: Part Two (2024) [2160p BluRay]</text>
<text x="656" y="216" textLength="67.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">7.82 GB</text>
<text x="742.4" y="216" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#86d6a2">YTS</text>
<rect x="784.9" y="200" width="1.4" height="22" fill="#a78bfa"/>
<text x="60.8" y="238" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Movies</text>
<rect x="208.9" y="222" width="1.4" height="22" fill="#a78bfa"/>
<rect x="262.4" y="222" width="9.6" height="22" fill="#7c5cd6"/>
<rect x="272" y="222" width="9.6" height="22" fill="#8060d9"/>
<rect x="281.6" y="222" width="9.6" height="22" fill="#8465dd"/>
<rect x="291.2" y="222" width="9.6" height="22" fill="#8869e0"/>
<rect x="300.8" y="222" width="9.6" height="22" fill="#8c6ee4"/>
<rect x="310.4" y="222" width="9.6" height="22" fill="#9072e7"/>
<rect x="320" y="222" width="9.6" height="22" fill="#9577eb"/>
<rect x="329.6" y="222" width="9.6" height="22" fill="#997bee"/>
<rect x="339.2" y="222" width="9.6" height="22" fill="#9d80f1"/>
<rect x="348.8" y="222" width="9.6" height="22" fill="#a184f5"/>
<rect x="358.4" y="222" width="9.6" height="22" fill="#a589f8"/>
<rect x="368" y="222" width="9.6" height="22" fill="#a98dfa"/>
<rect x="377.6" y="222" width="9.6" height="22" fill="#ae91fb"/>
<rect x="387.2" y="222" width="9.6" height="22" fill="#b395fb"/>
<text x="396.8" y="238" textLength="76.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#6b6577">░░░░░░░░</text>
<text x="492.8" y="238" textLength="211.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">64% 7.7 MB/s •41 6m</text>
<rect x="784.9" y="222" width="1.4" height="22" fill="#a78bfa"/>
<text x="60.8" y="260" textLength="19.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">TV</text>
<rect x="208.9" y="244" width="1.4" height="22" fill="#a78bfa"/>
<rect x="784.9" y="244" width="1.4" height="22" fill="#a78bfa"/>
<text x="60.8" y="282" textLength="48" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Anime</text>
<rect x="208.9" y="266" width="1.4" height="22" fill="#a78bfa"/>
<text x="224" y="282" textLength="230.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Recently downloaded (2)</text>
<rect x="784.9" y="266" width="1.4" height="22" fill="#a78bfa"/>
<rect x="208.9" y="288" width="1.4" height="22" fill="#a78bfa"/>
<text x="243.2" y="304" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#86d6a2" fill-opacity="0.55"></text>
<text x="262.4" y="304" textLength="230.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Elden Ring: Shadow of t…</text>
<text x="521.6" y="304" textLength="76.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">50.29 GB</text>
<text x="656" y="304" textLength="67.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">1hr ago</text>
<text x="752" y="304" textLength="19.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa" fill-opacity="0.55">FG</text>
<rect x="784.9" y="288" width="1.4" height="22" fill="#a78bfa"/>
<rect x="41.6" y="310" width="4.8" height="22" fill="#6b6577"/>
<text x="60.8" y="326" textLength="86.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">Downloads</text>
<text x="156.8" y="326" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">(1)</text>
<rect x="208.9" y="310" width="1.4" height="22" fill="#a78bfa"/>
<text x="243.2" y="326" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#86d6a2" fill-opacity="0.55"></text>
<text x="262.4" y="326" textLength="230.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Breaking Bad S05E14 108…</text>
<text x="531.2" y="326" textLength="67.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">1.49 GB</text>
<text x="627.2" y="326" textLength="96" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">1d 1hr ago</text>
<text x="732.8" y="326" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#f0c560" fill-opacity="0.55">EZTV</text>
<rect x="784.9" y="310" width="1.4" height="22" fill="#a78bfa"/>
<rect x="208.9" y="332" width="1.4" height="22" fill="#a78bfa"/>
<rect x="784.9" y="332" width="1.4" height="22" fill="#a78bfa"/>
<rect x="208.9" y="354" width="1.4" height="22" fill="#a78bfa"/>
<rect x="784.9" y="354" width="1.4" height="22" fill="#a78bfa"/>
<text x="204.8" y="392" textLength="585.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa">╰───────────────────────────────────────────────────────────╯</text>
<text x="41.6" y="414" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">p</text>
<text x="60.8" y="414" textLength="48" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Pause</text>
<text x="137.6" y="414" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">c</text>
<text x="156.8" y="414" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Cancel</text>
<text x="243.2" y="414" textLength="28.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">tab</text>
<text x="281.6" y="414" textLength="105.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Switch pane</text>
<text x="416" y="414" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">?</text>
<text x="435.2" y="414" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Keys</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+60
View File
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="832" height="386" viewBox="0 0 832 386" style="max-width: 832px; width: 100%; height: auto;" role="img">
<rect width="100%" height="100%" rx="14" fill="#0c0b14"/>
<rect x="0.5" y="0.5" width="831" height="385" rx="14" fill="none" stroke="#272430" stroke-width="1"/>
<text x="416" y="23" text-anchor="middle" font-family='ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace' font-size="13" fill="#8a8a8a">torlink</text>
<g font-family='ui-monospace, "Cascadia Mono", "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace' font-size="16" fill="#ddd8ea">
<text x="339.2" y="84" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#5ae87a" font-weight="600">𐓏</text>
<rect x="291.2" y="90" width="9.6" height="11" fill="#faf5ff"/>
<rect x="300.8" y="90" width="9.6" height="22" fill="#f5ecff"/>
<rect x="310.4" y="90" width="9.6" height="11" fill="#f0e2ff"/>
<rect x="329.6" y="90" width="9.6" height="22" fill="#e6cffe"/>
<rect x="339.2" y="90" width="9.6" height="11" fill="#e1c5fe"/>
<rect x="348.8" y="90" width="9.6" height="22" fill="#dcbcfe"/>
<rect x="368" y="90" width="9.6" height="22" fill="#d3b0fe"/>
<rect x="377.6" y="90" width="9.6" height="11" fill="#d0adfd"/>
<rect x="387.2" y="90" width="9.6" height="22" fill="#ccaafd"/>
<rect x="406.4" y="90" width="9.6" height="22" fill="#c4a4fc"/>
<rect x="444.8" y="90" width="9.6" height="22" fill="#b597fb"/>
<rect x="464" y="90" width="9.6" height="22" fill="#ae91fb"/>
<rect x="473.6" y="101" width="9.6" height="11" fill="#aa8efa"/>
<rect x="492.8" y="90" width="9.6" height="22" fill="#a487f7"/>
<rect x="512" y="90" width="9.6" height="22" fill="#9e81f3"/>
<rect x="521.6" y="101" width="9.6" height="11" fill="#9b7ef0"/>
<rect x="531.2" y="90" width="9.6" height="11" fill="#997bee"/>
<rect x="300.8" y="112" width="9.6" height="22" fill="#9375e9"/>
<rect x="329.6" y="112" width="9.6" height="22" fill="#8b6ce2"/>
<rect x="339.2" y="123" width="9.6" height="11" fill="#8869e0"/>
<rect x="348.8" y="112" width="9.6" height="22" fill="#8566de"/>
<rect x="368" y="112" width="9.6" height="22" fill="#8060d9"/>
<rect x="377.6" y="112" width="9.6" height="11" fill="#7d5dd7"/>
<rect x="387.2" y="123" width="9.6" height="11" fill="#7a5bd3"/>
<rect x="406.4" y="112" width="9.6" height="22" fill="#7456c9"/>
<rect x="416" y="123" width="9.6" height="11" fill="#7154c4"/>
<rect x="425.6" y="123" width="9.6" height="11" fill="#6e52c0"/>
<rect x="444.8" y="112" width="9.6" height="22" fill="#684eb6"/>
<rect x="464" y="112" width="9.6" height="22" fill="#6249ac"/>
<rect x="483.2" y="112" width="9.6" height="11" fill="#5b45a2"/>
<rect x="492.8" y="112" width="9.6" height="22" fill="#58439d"/>
<rect x="512" y="112" width="9.6" height="22" fill="#523e94"/>
<rect x="531.2" y="112" width="9.6" height="22" fill="#4c3a8a"/>
<text x="195.2" y="194" textLength="441.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#e9e4f5">A curated, terminal-native torrent downloader.</text>
<text x="262.4" y="216" textLength="316.8" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">games · movies · tv · anime</text>
<text x="118.4" y="260" textLength="19.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa">╭─</text>
<text x="147.2" y="260" textLength="57.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa" font-weight="600">Search</text>
<text x="214.4" y="260" textLength="499.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa">───────────────────────────────────────────────────╮</text>
<rect x="122.5" y="266" width="1.4" height="22" fill="#a78bfa"/>
<text x="137.6" y="282" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa"></text>
<rect x="156.8" y="266" width="9.6" height="22" fill="#ddd8ea"/>
<text x="156.8" y="282" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#0c0b14">S</text>
<text x="166.4" y="282" textLength="240" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">earch, or paste a magnet…</text>
<rect x="708.1" y="266" width="1.4" height="22" fill="#a78bfa"/>
<text x="118.4" y="304" textLength="595.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#a78bfa">╰────────────────────────────────────────────────────────────╯</text>
<text x="233.6" y="348" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6"></text>
<text x="252.8" y="348" textLength="153.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">search · empty</text>
<text x="416" y="348" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6"></text>
<text x="435.2" y="348" textLength="86.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">browse ·</text>
<text x="540.8" y="348" textLength="19.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#b9a7e6">^c</text>
<text x="569.6" y="348" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">quit</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

+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 {};
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import { parseCliArgs } from "./args";
describe("parseCliArgs", () => {
it("defaults to run with no args", () => {
expect(parseCliArgs([])).toEqual({ kind: "run" });
});
it("parses version and help flags", () => {
expect(parseCliArgs(["--version"])).toEqual({ kind: "version" });
expect(parseCliArgs(["-v"])).toEqual({ kind: "version" });
expect(parseCliArgs(["--help"])).toEqual({ kind: "help" });
expect(parseCliArgs(["-h"])).toEqual({ kind: "help" });
});
it("launches a magnet", () => {
expect(parseCliArgs(["magnet:?xt=urn:btih:abc"])).toEqual({
kind: "run",
initialMagnet: "magnet:?xt=urn:btih:abc",
});
});
it("launches a .torrent file", () => {
expect(parseCliArgs(["./Foo.torrent"])).toEqual({
kind: "run",
initialTorrent: "./Foo.torrent",
});
});
it("rejects unknown arguments", () => {
expect(parseCliArgs(["--nope"])).toEqual({ kind: "invalid", arg: "--nope" });
});
});
+29
View File
@@ -0,0 +1,29 @@
export type CliCommand =
| { kind: "version" }
| { kind: "help" }
| { kind: "run"; initialMagnet?: string; initialTorrent?: string }
| { kind: "invalid"; arg: string };
export function parseCliArgs(argv: string[]): CliCommand {
const args = argv.filter((a) => a.trim() !== "");
if (args.length === 0) return { kind: "run" };
const a = args[0]!;
if (a === "--version" || a === "-v") return { kind: "version" };
if (a === "--help" || a === "-h") return { kind: "help" };
if (/^magnet:\?/i.test(a)) return { kind: "run", initialMagnet: a };
if (/\.torrent$/i.test(a)) return { kind: "run", initialTorrent: a };
return { kind: "invalid", arg: a };
}
export const HELP_TEXT = `torlink, terminal-native torrent search
usage
torlink open the search TUI
torlink "magnet:?xt=..." start a download on launch
torlink path/to/file.torrent open a .torrent file on launch
torlink --version print the version
once open: type to search every source at once, enter to run, arrows to move,
d to download, ? for keys
tip: quote magnet links (they contain & characters)
`;
+36
View File
@@ -0,0 +1,36 @@
import { promises as fs } from "node:fs";
import { configFile, defaultDownloadDir } from "./paths";
import { serializeWrites, writeJsonAtomic } from "../util/atomic";
export interface Config {
downloadDir: string;
}
export const defaultConfig: Config = {
downloadDir: defaultDownloadDir,
};
export async function loadConfig(): Promise<Config> {
let raw: string;
try {
raw = await fs.readFile(configFile, "utf8");
} catch {
return { ...defaultConfig };
}
try {
const parsed = JSON.parse(raw) as Partial<Config>;
const cfg = { ...defaultConfig, ...parsed };
if (!cfg.downloadDir || typeof cfg.downloadDir !== "string") {
cfg.downloadDir = defaultDownloadDir;
}
return cfg;
} catch {
return { ...defaultConfig };
}
}
const write = serializeWrites();
export function saveConfig(config: Config): Promise<void> {
return write(() => writeJsonAtomic(configFile, config));
}
+15
View File
@@ -0,0 +1,15 @@
import os from "node:os";
import path from "node:path";
import envPaths from "env-paths";
export const APP_NAME = "torlink";
export const paths = envPaths(APP_NAME, { suffix: "" });
export const defaultDownloadDir = path.join(os.homedir(), "Downloads", APP_NAME);
export const configFile = path.join(paths.config, "config.json");
export const queueFile = path.join(paths.data, "queue.json");
export const historyFile = path.join(paths.data, "history.json");
+114
View File
@@ -0,0 +1,114 @@
import WebTorrent, { type Torrent } from "webtorrent";
export interface TorrentProgress {
progress: number;
downloaded: number;
total: number;
speed: number;
peers: number;
timeRemaining: number;
name: string;
}
export interface TorrentMeta {
name: string;
total: number;
files: number;
}
export interface AddHandlers {
onMetadata?: (meta: TorrentMeta) => void;
onDone?: () => void;
onError?: (message: string) => void;
}
function message(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
export class TorrentEngine {
private client: WebTorrent | null = null;
private torrents = new Map<string, Torrent>();
private ensureClient(): WebTorrent {
if (!this.client) {
this.client = new WebTorrent();
this.client.on("error", () => {});
}
return this.client;
}
add(id: string, magnet: string, dir: string, handlers: AddHandlers): void {
const client = this.ensureClient();
const existing = this.torrents.get(id);
if (existing) {
this.torrents.delete(id);
try {
existing.destroy();
} catch {}
}
let torrent: Torrent;
try {
torrent = client.add(magnet, { path: dir });
} catch (e) {
handlers.onError?.(message(e));
return;
}
this.torrents.set(id, torrent);
torrent.on("metadata", () => {
handlers.onMetadata?.({
name: torrent.name,
total: torrent.length,
files: torrent.files?.length ?? 0,
});
});
torrent.on("done", () => {
handlers.onDone?.();
this.torrents.delete(id);
try {
torrent.destroy();
} catch {}
});
torrent.on("error", (err: unknown) => {
handlers.onError?.(message(err));
this.torrents.delete(id);
try {
torrent.destroy();
} catch {}
});
}
stats(id: string): TorrentProgress | null {
const t = this.torrents.get(id);
if (!t) return null;
return {
progress: t.progress,
downloaded: t.downloaded,
total: t.length,
speed: t.downloadSpeed,
peers: t.numPeers,
timeRemaining: t.timeRemaining,
name: t.name,
};
}
remove(id: string): void {
const t = this.torrents.get(id);
this.torrents.delete(id);
if (t) {
try {
t.destroy();
} catch {}
}
}
destroy(): void {
this.torrents.clear();
try {
this.client?.destroy();
} catch {}
this.client = null;
}
}
+43
View File
@@ -0,0 +1,43 @@
import { promises as fs } from "node:fs";
import { historyFile } from "../config/paths";
import { serializeWrites, writeJsonAtomic } from "../util/atomic";
import type { SourceId } from "../sources/types";
export const HISTORY_CAP = 500;
export interface HistoryItem {
id: string;
name: string;
source?: SourceId;
sizeBytes: number;
magnet: string;
dir: string;
completedAt: number;
}
const write = serializeWrites();
export function saveHistory(items: HistoryItem[]): Promise<void> {
return write(() => writeJsonAtomic(historyFile, items.slice(0, HISTORY_CAP)));
}
function isHistoryItem(v: unknown): v is HistoryItem {
if (!v || typeof v !== "object") return false;
const r = v as Record<string, unknown>;
return typeof r.id === "string" && typeof r.name === "string" && typeof r.magnet === "string";
}
export async function loadHistory(): Promise<HistoryItem[]> {
let raw: string;
try {
raw = await fs.readFile(historyFile, "utf8");
} catch {
return [];
}
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed.filter(isHistoryItem).slice(0, HISTORY_CAP) : [];
} catch {
return [];
}
}
+41
View File
@@ -0,0 +1,41 @@
import { promises as fs, mkdirSync, writeFileSync, renameSync } from "node:fs";
import path from "node:path";
import { queueFile } from "../config/paths";
import { serializeWrites, writeJsonAtomic } from "../util/atomic";
import type { QueueItem } from "./types";
const write = serializeWrites();
export function saveQueue(items: QueueItem[]): Promise<void> {
return write(() => writeJsonAtomic(queueFile, items));
}
export function saveQueueSync(items: QueueItem[]): void {
try {
mkdirSync(path.dirname(queueFile), { recursive: true });
const tmp = `${queueFile}.sync.tmp`;
writeFileSync(tmp, JSON.stringify(items, null, 2), "utf8");
renameSync(tmp, queueFile);
} catch {}
}
function isQueueItem(v: unknown): v is QueueItem {
if (!v || typeof v !== "object") return false;
const r = v as Record<string, unknown>;
return typeof r.id === "string" && typeof r.magnet === "string";
}
export async function loadQueue(): Promise<QueueItem[]> {
let raw: string;
try {
raw = await fs.readFile(queueFile, "utf8");
} catch {
return [];
}
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed.filter(isQueueItem) : [];
} catch {
return [];
}
}
+265
View File
@@ -0,0 +1,265 @@
import { EventEmitter } from "node:events";
import { TorrentEngine } from "./engine";
import { saveQueue, saveQueueSync } from "./persist";
import { saveHistory, type HistoryItem } from "./history";
import type { QueueItem } from "./types";
import type { SourceId } from "../sources/types";
const POLL_MS = 500;
const HISTORY_MAX = 500;
export interface AddInput {
id: string;
name: string;
magnet: string;
source?: SourceId;
sizeBytes?: number;
}
export class DownloadQueue extends EventEmitter {
private items = new Map<string, QueueItem>();
private engine = new TorrentEngine();
private poll: ReturnType<typeof setInterval> | null = null;
private history: HistoryItem[] = [];
getItems(): QueueItem[] {
return [...this.items.values()].sort((a, b) => b.addedAt - a.addedAt);
}
get activeCount(): number {
let n = 0;
for (const it of this.items.values()) if (it.status === "downloading") n++;
return n;
}
has(id: string): boolean {
return this.items.has(id);
}
add(input: AddInput, dir: string): void {
const existing = this.items.get(input.id);
if (existing && existing.status !== "failed") return;
const item: QueueItem = existing
? { ...existing, status: "downloading", error: undefined, speed: 0 }
: {
id: input.id,
name: input.name,
source: input.source,
magnet: input.magnet,
dir,
status: "downloading",
progress: 0,
totalBytes: input.sizeBytes ?? 0,
downloadedBytes: 0,
speed: 0,
peers: 0,
addedAt: Date.now(),
};
this.items.set(item.id, item);
this.startEngine(item);
this.ensurePoll();
this.changed();
void this.persist();
}
private startEngine(item: QueueItem): void {
this.engine.add(item.id, item.magnet, item.dir, {
onMetadata: (meta) => {
const it = this.items.get(item.id);
if (!it) return;
if (meta.name) it.name = meta.name;
if (meta.total) it.totalBytes = meta.total;
it.files = meta.files;
this.changed();
void this.persist();
},
onDone: () => {
const it = this.items.get(item.id);
if (!it) return;
if (it.totalBytes) it.downloadedBytes = it.totalBytes;
this.complete(it);
},
onError: (msg) => {
const it = this.items.get(item.id);
if (!it) return;
it.status = "failed";
it.error = msg;
it.speed = 0;
it.peers = 0;
this.changed();
void this.persist();
this.maybeStopPoll();
},
});
}
private complete(it: QueueItem): void {
this.recordHistory(it);
this.items.delete(it.id);
this.emit("completed", it.name);
this.changed();
void this.persist();
this.maybeStopPoll();
}
private tick(): void {
let any = false;
for (const it of this.items.values()) {
if (it.status !== "downloading") continue;
const s = this.engine.stats(it.id);
if (!s) continue;
it.progress = Math.min(100, Math.round(s.progress * 100));
it.downloadedBytes = s.downloaded;
if (s.total) it.totalBytes = s.total;
it.speed = s.speed;
it.peers = s.peers;
it.eta =
s.timeRemaining > 0 && Number.isFinite(s.timeRemaining)
? s.timeRemaining / 1000
: undefined;
if (s.name) it.name = s.name;
any = true;
}
if (any) this.changed();
}
private ensurePoll(): void {
if (this.poll) return;
this.poll = setInterval(() => this.tick(), POLL_MS);
this.poll.unref();
}
private maybeStopPoll(): void {
if (this.activeCount === 0 && this.poll) {
clearInterval(this.poll);
this.poll = null;
}
}
pause(id: string): void {
const it = this.items.get(id);
if (!it || it.status !== "downloading") return;
it.status = "paused";
it.speed = 0;
it.peers = 0;
it.eta = undefined;
this.engine.remove(id);
this.changed();
void this.persist();
this.maybeStopPoll();
}
resume(id: string): void {
const it = this.items.get(id);
if (!it || it.status !== "paused") return;
it.status = "downloading";
this.startEngine(it);
this.ensurePoll();
this.changed();
void this.persist();
}
togglePause(id: string): void {
const it = this.items.get(id);
if (!it) return;
if (it.status === "downloading") this.pause(id);
else if (it.status === "paused") this.resume(id);
}
cancel(id: string): void {
if (!this.items.has(id)) return;
this.engine.remove(id);
this.items.delete(id);
this.changed();
void this.persist();
this.maybeStopPoll();
}
retry(id: string): void {
const it = this.items.get(id);
if (!it || it.status !== "failed") return;
it.status = "downloading";
it.error = undefined;
this.startEngine(it);
this.ensurePoll();
this.changed();
void this.persist();
}
retryFailed(): void {
for (const it of [...this.items.values()]) {
if (it.status === "failed") this.retry(it.id);
}
}
restore(items: QueueItem[]): void {
for (const raw of items) {
this.items.set(raw.id, raw);
if (raw.status === "downloading") this.startEngine(raw);
}
if (this.activeCount > 0) this.ensurePoll();
this.changed();
}
restoreHistory(items: HistoryItem[]): void {
this.history = items.slice(0, HISTORY_MAX);
}
getHistory(): HistoryItem[] {
return this.history;
}
private recordHistory(it: QueueItem): void {
const rec: HistoryItem = {
id: it.id,
name: it.name,
source: it.source,
sizeBytes: it.totalBytes,
magnet: it.magnet,
dir: it.dir,
completedAt: Date.now(),
};
this.history = [rec, ...this.history.filter((h) => h.id !== it.id)].slice(0, HISTORY_MAX);
void saveHistory(this.history).catch(() => {});
}
removeHistory(id: string): void {
const next = this.history.filter((h) => h.id !== id);
if (next.length === this.history.length) return;
this.history = next;
void saveHistory(this.history).catch(() => {});
this.changed();
}
clearHistory(): void {
if (this.history.length === 0) return;
this.history = [];
void saveHistory(this.history).catch(() => {});
this.changed();
}
private changed(): void {
this.emit("update");
}
private async persist(): Promise<void> {
await saveQueue(this.getItems()).catch(() => {});
}
suspend(): void {
for (const it of this.items.values()) {
if (it.status === "downloading") {
it.status = "paused";
it.speed = 0;
it.peers = 0;
it.eta = undefined;
}
}
saveQueueSync(this.getItems());
if (this.poll) {
clearInterval(this.poll);
this.poll = null;
}
this.engine.destroy();
}
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from "vitest";
import { reconcileQueue } from "./reconcile";
import type { QueueItem } from "./types";
function item(over: Partial<QueueItem>): QueueItem {
return {
id: "x",
name: "n",
magnet: "m",
dir: "d",
status: "downloading",
progress: 50,
totalBytes: 100,
downloadedBytes: 50,
speed: 99,
peers: 7,
eta: 12,
addedAt: 1,
...over,
};
}
describe("reconcileQueue", () => {
it("drops completed entries, dedupes, and resets live counters", () => {
const out = reconcileQueue([
item({ id: "a", status: "completed" }),
item({ id: "b", status: "paused" }),
item({ id: "c", status: "failed" }),
item({ id: "d", status: "downloading" }),
item({ id: "d", status: "downloading" }),
]);
expect(out.map((i) => i.id)).toEqual(["b", "c", "d"]);
expect(out.map((i) => i.status)).toEqual(["paused", "failed", "downloading"]);
for (const it of out) {
expect(it.speed).toBe(0);
expect(it.peers).toBe(0);
expect(it.eta).toBeUndefined();
}
});
it("skips entries without an id", () => {
const out = reconcileQueue([item({ id: "" }), item({ id: "ok" })]);
expect(out.map((i) => i.id)).toEqual(["ok"]);
});
});
+15
View File
@@ -0,0 +1,15 @@
import type { QueueItem } from "./types";
export function reconcileQueue(items: QueueItem[]): QueueItem[] {
const seen = new Set<string>();
const out: QueueItem[] = [];
for (const it of items) {
if (!it?.id || seen.has(it.id)) continue;
seen.add(it.id);
if (it.status === "completed") continue;
const status =
it.status === "failed" ? "failed" : it.status === "paused" ? "paused" : "downloading";
out.push({ ...it, status, speed: 0, peers: 0, eta: undefined });
}
return out;
}
+21
View File
@@ -0,0 +1,21 @@
import type { SourceId } from "../sources/types";
export type DownloadStatus = "downloading" | "paused" | "completed" | "failed";
export interface QueueItem {
id: string;
name: string;
source?: SourceId;
magnet: string;
dir: string;
status: DownloadStatus;
progress: number;
totalBytes: number;
downloadedBytes: number;
speed: number;
peers: number;
eta?: number;
files?: number;
error?: string;
addedAt: number;
}
+71
View File
@@ -0,0 +1,71 @@
import { render } from "ink";
import { parseCliArgs, HELP_TEXT } from "./cli/args";
import { VERSION } from "./version";
import { App } from "./ui/App";
const cmd = parseCliArgs(process.argv.slice(2));
if (cmd.kind === "help") {
console.log(HELP_TEXT);
process.exit(0);
}
if (cmd.kind === "version") {
console.log(`torlink v${VERSION}`);
process.exit(0);
}
if (cmd.kind === "invalid") {
console.error(`error: unknown argument '${cmd.arg}'\n`);
console.error(HELP_TEXT);
process.exit(1);
}
process.stdout.write("\x1b[?1049h\x1b[22;0t\x1b]0;torlink\x07");
if (process.platform === "win32") process.title = "torlink";
let restored = false;
function restoreTerminal(): void {
if (restored) return;
restored = true;
process.stdout.write("\x1b[?1000l\x1b[?1006l\x1b[?25h\x1b[23;0t\x1b[?1049l");
}
let exiting = false;
function forceExit(code = 0): void {
if (exiting) return;
exiting = true;
try {
app?.unmount();
} catch {}
restoreTerminal();
setTimeout(() => process.exit(code), 80).unref();
}
const app = render(
<App
initialMagnet={cmd.initialMagnet}
initialTorrent={cmd.initialTorrent}
onQuit={() => forceExit(0)}
/>,
{ exitOnCtrlC: false },
);
app
.waitUntilExit()
.then(() => forceExit(0))
.catch((err) => {
restoreTerminal();
console.error(err);
process.exit(1);
});
process.on("SIGINT", () => forceExit(0));
process.on("SIGTERM", () => forceExit(0));
process.on("exit", restoreTerminal);
process.on("uncaughtException", (err) => {
restoreTerminal();
console.error(err);
process.exit(1);
});
+9
View File
@@ -0,0 +1,9 @@
declare module "parse-torrent" {
interface ParsedTorrent {
infoHash: string;
name?: string;
}
export default function parseTorrent(
torrentId: Uint8Array | string,
): Promise<ParsedTorrent>;
}
+28
View File
@@ -0,0 +1,28 @@
import type { SearchOptions, Source, TorrentResult } from "./types";
const TTL_MS = 5 * 60 * 1000;
interface Entry {
at: number;
results: TorrentResult[];
}
const cache = new Map<string, Entry>();
function key(sourceId: string, query: string): string {
return `${sourceId}::${query.trim().toLowerCase()}`;
}
export async function cachedSearch(
source: Source,
query: string,
opts: SearchOptions = {},
): Promise<TorrentResult[]> {
const k = key(source.id, query);
const hit = cache.get(k);
if (hit && Date.now() - hit.at < TTL_MS) return hit.results;
const results = await source.search(query, opts);
cache.set(k, { at: Date.now(), results });
return results;
}
+58
View File
@@ -0,0 +1,58 @@
import { fetchResilient, HttpError, USER_AGENT } from "../util/net";
import { buildMagnet } from "./magnet";
import type { SearchOptions, Source, TorrentResult } from "./types";
const API = "https://eztvx.to/api/get-torrents";
interface EztvTorrent {
title?: string;
filename?: string;
hash?: string;
magnet_url?: string;
seeds?: number;
peers?: number;
size_bytes?: string | number;
date_released_unix?: number;
}
interface EztvResponse {
torrents?: EztvTorrent[];
}
async function search(query: string, opts: SearchOptions = {}): Promise<TorrentResult[]> {
if (query.trim()) return [];
const res = await fetchResilient(`${API}?limit=100&page=1`, {
headers: { "User-Agent": USER_AGENT },
signal: opts.signal,
retries: 1,
});
if (!res.ok) throw new HttpError(res.status, `EZTV returned ${res.status}`);
const json = (await res.json()) as EztvResponse;
const out: TorrentResult[] = [];
for (const t of json.torrents ?? []) {
const hash = (t.hash ?? "").toLowerCase();
const name = t.title || t.filename || hash;
const magnet = t.magnet_url || (hash ? buildMagnet(hash, name) : "");
if (!magnet || !hash) continue;
out.push({
infoHash: hash,
name,
sizeBytes: Number(t.size_bytes ?? 0) || 0,
seeders: t.seeds ?? 0,
leechers: t.peers ?? 0,
source: "eztv",
magnet,
added: t.date_released_unix,
});
}
return out;
}
export const eztv: Source = {
id: "eztv",
label: "EZTV",
group: "TV",
homepage: "https://eztvx.to",
search,
};
+12
View File
@@ -0,0 +1,12 @@
import { fetchWordpressRss } from "./rss";
import type { Source } from "./types";
const HOME = "https://fitgirl-repacks.site";
export const fitgirl: Source = {
id: "fitgirl",
label: "FitGirl",
group: "Games",
homepage: HOME,
search: (query, opts) => fetchWordpressRss(HOME, "fitgirl", query, opts),
};
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { parseMagnet, normalizeInfoHash, buildMagnet } from "./magnet";
describe("parseMagnet", () => {
it("keeps a full 40-char hex info hash", () => {
const hash = "abcdef0123456789abcdef0123456789abcdef01";
const m = parseMagnet(`magnet:?xt=urn:btih:${hash}&dn=Cool+Movie`);
expect(m?.infoHash).toBe(hash);
expect(m?.infoHash.length).toBe(40);
expect(m?.name).toBe("Cool Movie");
});
it("decodes a 32-char base32 hash to 40-char hex", () => {
const m = parseMagnet("magnet:?xt=urn:btih:MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U&dn=X");
expect(m?.infoHash.length).toBe(40);
expect(m?.infoHash).toMatch(/^[a-f0-9]{40}$/);
});
it("falls back to the hash as the name when dn is absent", () => {
const hash = "abcdef0123456789abcdef0123456789abcdef01";
const m = parseMagnet(`magnet:?xt=urn:btih:${hash}`);
expect(m?.name).toBe(hash);
});
it("returns null for non-magnets and malformed hashes", () => {
expect(parseMagnet("not a magnet")).toBeNull();
expect(parseMagnet("magnet:?xt=urn:btih:tooshort")).toBeNull();
expect(parseMagnet("prefix magnet:?xt=urn:btih:" + "a".repeat(40))).toBeNull();
});
});
describe("normalizeInfoHash", () => {
it("lowercases 40-char hex", () => {
expect(normalizeInfoHash("ABCDEF0123456789ABCDEF0123456789ABCDEF01")).toBe(
"abcdef0123456789abcdef0123456789abcdef01",
);
});
it("decodes 32-char base32 to 40-char hex", () => {
expect(normalizeInfoHash("MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U")).toMatch(/^[a-f0-9]{40}$/);
});
});
describe("buildMagnet", () => {
it("builds a magnet with encoded name and trackers", () => {
const out = buildMagnet("abc123", "My Movie 2024");
expect(out).toContain("xt=urn:btih:abc123");
expect(out).toContain("dn=My%20Movie%202024");
expect(out).toContain("&tr=");
});
});
+61
View File
@@ -0,0 +1,61 @@
const TRACKERS = [
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.demonii.com:1337/announce",
"udp://tracker.openbittorrent.com:6969/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://exodus.desync.com:6969/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.dler.org:6969/announce",
];
export function buildMagnet(infoHash: string, name: string): string {
const dn = encodeURIComponent(name);
const tr = TRACKERS.map((t) => `&tr=${encodeURIComponent(t)}`).join("");
return `magnet:?xt=urn:btih:${infoHash}&dn=${dn}${tr}`;
}
const MAGNET_RE = /xt=urn:btih:([a-f0-9]{40}|[a-z2-7]{32})/i;
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
function base32ToHex(b32: string): string | null {
let bits = 0;
let value = 0;
let out = "";
for (const c of b32.toUpperCase()) {
const idx = BASE32.indexOf(c);
if (idx === -1) return null;
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
out += ((value >>> bits) & 0xff).toString(16).padStart(2, "0");
value &= (1 << bits) - 1;
}
}
return out.length === 40 ? out : null;
}
export function normalizeInfoHash(raw: string): string {
return raw.length === 32 ? (base32ToHex(raw) ?? raw.toLowerCase()) : raw.toLowerCase();
}
export interface ParsedMagnet {
infoHash: string;
name: string;
magnet: string;
}
export function parseMagnet(input: string): ParsedMagnet | null {
const s = input.trim();
if (!/^magnet:\?/i.test(s)) return null;
const m = MAGNET_RE.exec(s);
if (!m) return null;
const infoHash = normalizeInfoHash(m[1]!);
let name = infoHash;
try {
const dn = new URL(s).searchParams.get("dn");
if (dn) name = dn;
} catch {}
return { infoHash, name, magnet: s };
}
+50
View File
@@ -0,0 +1,50 @@
import { fetchResilient, HttpError, USER_AGENT } from "../util/net";
import { buildMagnet } from "./magnet";
import { unescapeEntities } from "./rss";
import { parseSize } from "../util/format";
import type { SearchOptions, Source, TorrentResult } from "./types";
const BASE = "https://nyaa.si/";
function tag(item: string, name: string): string {
return item.match(new RegExp(`<${name}>(?:<!\\[CDATA\\[)?(.*?)(?:\\]\\]>)?</${name}>`, "s"))?.[1]?.trim() ?? "";
}
async function search(query: string, opts: SearchOptions = {}): Promise<TorrentResult[]> {
const params = new URLSearchParams({ page: "rss", q: query.trim(), c: "0_0", f: "0" });
const res = await fetchResilient(`${BASE}?${params.toString()}`, {
headers: { "User-Agent": USER_AGENT },
signal: opts.signal,
});
if (!res.ok) throw new HttpError(res.status, `Nyaa returned ${res.status}`);
const xml = await res.text();
const out: TorrentResult[] = [];
for (const item of xml.split("<item>").slice(1)) {
const infoHash = tag(item, "nyaa:infoHash").toLowerCase();
const name = unescapeEntities(tag(item, "title"));
if (!infoHash || !name) continue;
const seeders = Number(tag(item, "nyaa:seeders"));
const leechers = Number(tag(item, "nyaa:leechers"));
const dateStr = tag(item, "pubDate");
out.push({
infoHash,
name,
sizeBytes: parseSize(tag(item, "nyaa:size")),
seeders: Number.isFinite(seeders) ? seeders : 0,
leechers: Number.isFinite(leechers) ? leechers : 0,
source: "nyaa",
magnet: buildMagnet(infoHash, name),
added: dateStr ? new Date(dateStr).getTime() / 1000 : undefined,
});
}
return out;
}
export const nyaa: Source = {
id: "nyaa",
label: "Nyaa",
group: "Anime",
homepage: "https://nyaa.si",
search,
};
+91
View File
@@ -0,0 +1,91 @@
import { fetchResilient, HttpError, USER_AGENT } from "../util/net";
import { buildMagnet } from "./magnet";
import type { SearchOptions, Source, SourceId, TorrentResult } from "./types";
const API = "https://apibay.org";
const MOVIE_CATS = new Set([201, 202, 207, 209]);
const TV_CATS = new Set([205, 208]);
const TOP_MOVIES = `${API}/precompiled/data_top100_207.json`;
const TOP_TV = `${API}/precompiled/data_top100_208.json`;
interface ApibayItem {
id?: string;
name?: string;
info_hash?: string;
seeders?: string;
leechers?: string;
num_files?: string;
size?: string;
added?: string;
category?: string;
}
const ZERO_HASH = "0000000000000000000000000000000000000000";
function toResult(it: ApibayItem, source: SourceId): TorrentResult | null {
const infoHash = (it.info_hash ?? "").toLowerCase();
if (!infoHash || infoHash === ZERO_HASH || it.id === "0") return null;
const name = it.name || "Unknown";
const numFiles = Number(it.num_files);
return {
infoHash,
name,
sizeBytes: Number(it.size) || 0,
seeders: Number(it.seeders) || 0,
leechers: Number(it.leechers) || 0,
numFiles: Number.isFinite(numFiles) && numFiles > 0 ? numFiles : undefined,
source,
magnet: buildMagnet(infoHash, name),
added: Number(it.added) || undefined,
};
}
async function fetchItems(url: string, opts: SearchOptions): Promise<ApibayItem[]> {
const res = await fetchResilient(url, {
headers: { "User-Agent": USER_AGENT },
signal: opts.signal,
retries: 1,
});
if (!res.ok) throw new HttpError(res.status, `Pirate Bay returned ${res.status}`);
const json = (await res.json()) as ApibayItem[];
return Array.isArray(json) ? json : [];
}
async function search(
query: string,
cats: Set<number>,
browseUrl: string,
source: SourceId,
opts: SearchOptions,
): Promise<TorrentResult[]> {
const q = query.trim();
const items = await fetchItems(
q ? `${API}/q.php?q=${encodeURIComponent(q)}` : browseUrl,
opts,
);
const out: TorrentResult[] = [];
for (const it of items) {
if (q && !cats.has(Number(it.category))) continue;
const r = toResult(it, source);
if (r) out.push(r);
}
return out;
}
export const tpbMovies: Source = {
id: "tpb-movies",
label: "TPB",
group: "Movies",
homepage: "https://thepiratebay.org",
search: (query, opts = {}) => search(query, MOVIE_CATS, TOP_MOVIES, "tpb-movies", opts),
};
export const tpbTv: Source = {
id: "tpb-tv",
label: "TPB",
group: "TV",
homepage: "https://thepiratebay.org",
search: (query, opts = {}) => search(query, TV_CATS, TOP_TV, "tpb-tv", opts),
};
+37
View File
@@ -0,0 +1,37 @@
import { eztv } from "./eztv";
import { fitgirl } from "./fitgirl";
import { nyaa } from "./nyaa";
import { subsplease } from "./subsplease";
import { solid } from "./solidtorrents";
import { tpbMovies, tpbTv } from "./piratebay";
import { x1337Movies, x1337Tv } from "./x1337";
import { yts } from "./yts";
import type { Source, SourceGroup, SourceId } from "./types";
export const SOURCES: readonly Source[] = [
fitgirl,
yts,
tpbMovies,
x1337Movies,
eztv,
solid,
tpbTv,
x1337Tv,
nyaa,
subsplease,
];
export const DEFAULT_SOURCE: Source = SOURCES[0]!;
export function getSource(id: SourceId): Source {
return SOURCES.find((s) => s.id === id) ?? DEFAULT_SOURCE;
}
const GROUP_ORDER: readonly SourceGroup[] = ["Games", "Movies", "TV", "Anime"];
export function sourcesByGroup(): { group: SourceGroup; sources: Source[] }[] {
return GROUP_ORDER.map((group) => ({
group,
sources: SOURCES.filter((s) => s.group === group),
})).filter((g) => g.sources.length > 0);
}
+51
View File
@@ -0,0 +1,51 @@
import { fetchResilient, HttpError, USER_AGENT } from "../util/net";
import type { SearchOptions, SourceId, TorrentResult } from "./types";
export function unescapeEntities(s: string): string {
return s
.replace(/&#0?38;|&amp;/g, "&")
.replace(/&#8211;|&#8212;/g, "-")
.replace(/&#8217;|&#0?39;|&apos;/g, "'")
.replace(/&#8220;|&#8221;|&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
function parseRssItems(xml: string, source: SourceId): TorrentResult[] {
const items = xml.split("<item>").slice(1);
const out: TorrentResult[] = [];
for (const item of items) {
const magnetMatch = item.match(/href="(magnet:\?xt=urn:btih:[^"]+)"/i);
if (!magnetMatch) continue;
const magnet = unescapeEntities(magnetMatch[1]!);
const infoHash = magnet.match(/urn:btih:([a-zA-Z0-9]+)/)?.[1]?.toLowerCase() ?? "";
if (!infoHash) continue;
const name = unescapeEntities(item.match(/<title>(.*?)<\/title>/)?.[1] ?? "Unknown Title");
const addedStr = item.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] ?? "";
const added = addedStr ? new Date(addedStr).getTime() / 1000 : 0;
out.push({ infoHash, name, sizeBytes: 0, seeders: 0, leechers: 0, source, magnet, added });
}
return out;
}
export async function fetchWordpressRss(
base: string,
source: SourceId,
query: string,
opts: SearchOptions = {},
): Promise<TorrentResult[]> {
const q = query.trim();
const url = q
? `${base}/?s=${encodeURIComponent(q)}&feed=rss2`
: `${base}/feed/`;
const res = await fetchResilient(url, {
headers: { "User-Agent": USER_AGENT },
signal: opts.signal,
});
if (!res.ok) throw new HttpError(res.status, `${source} feed returned ${res.status}`);
return parseRssItems(await res.text(), source);
}
+60
View File
@@ -0,0 +1,60 @@
import { fetchResilient, HttpError, USER_AGENT } from "../util/net";
import { buildMagnet } from "./magnet";
import type { SearchOptions, Source, TorrentResult } from "./types";
interface SolidResult {
infohash?: string;
title?: string;
size?: number;
seeders?: number;
leechers?: number;
updatedAt?: string;
}
interface SolidResponse {
success?: boolean;
results?: SolidResult[];
}
async function search(query: string, opts: SearchOptions = {}): Promise<TorrentResult[]> {
const q = query.trim() || "tv show";
const params = new URLSearchParams({ q });
const url = `https://solidtorrents.net/api/v1/search?${params.toString()}`;
const res = await fetchResilient(url, {
headers: { "User-Agent": USER_AGENT },
signal: opts.signal,
retries: 1,
});
if (!res.ok) throw new HttpError(res.status, `SolidTorrents returned ${res.status}`);
const json = (await res.json()) as SolidResponse;
const out: TorrentResult[] = [];
for (const item of json.results ?? []) {
if (!item.infohash) continue;
const infoHash = item.infohash.toLowerCase();
const name = item.title || "Unknown";
const added = item.updatedAt ? Math.floor(new Date(item.updatedAt).getTime() / 1000) : undefined;
out.push({
infoHash,
name,
sizeBytes: item.size ?? 0,
seeders: item.seeders ?? 0,
leechers: item.leechers ?? 0,
source: "solid",
magnet: buildMagnet(infoHash, name),
added,
});
}
return out;
}
export const solid: Source = {
id: "solid",
label: "Solid",
group: "TV",
homepage: "https://solidtorrents.net",
search,
};
+75
View File
@@ -0,0 +1,75 @@
import { fetchResilient, HttpError, USER_AGENT } from "../util/net";
import { parseMagnet } from "./magnet";
import type { SearchOptions, Source, TorrentResult } from "./types";
const API = "https://subsplease.org/api/";
const RES_PREFERENCE = ["1080", "720", "480"];
interface SpDownload {
res?: string;
magnet?: string;
}
interface SpEntry {
show?: string;
episode?: string;
release_date?: string;
downloads?: SpDownload[];
}
function pickBest(downloads: SpDownload[]): SpDownload | undefined {
for (const res of RES_PREFERENCE) {
const d = downloads.find((d) => d.res === res && d.magnet);
if (d) return d;
}
return downloads.find((d) => d.magnet);
}
async function search(query: string, opts: SearchOptions = {}): Promise<TorrentResult[]> {
const q = query.trim();
const params = new URLSearchParams({ tz: "UTC" });
if (q) {
params.set("f", "search");
params.set("s", q);
} else {
params.set("f", "latest");
}
const res = await fetchResilient(`${API}?${params.toString()}`, {
headers: { "User-Agent": USER_AGENT },
signal: opts.signal,
});
if (!res.ok) throw new HttpError(res.status, `SubsPlease returned ${res.status}`);
const json = (await res.json()) as Record<string, SpEntry> | unknown[];
if (!json || Array.isArray(json)) return [];
const out: TorrentResult[] = [];
for (const entry of Object.values(json)) {
const dl = pickBest(entry.downloads ?? []);
if (!dl?.magnet) continue;
const parsed = parseMagnet(dl.magnet);
if (!parsed) continue;
const show = entry.show ?? "Unknown";
const ep = entry.episode ? ` - ${entry.episode}` : "";
const sizeMatch = dl.magnet.match(/[?&]xl=(\d+)/);
out.push({
infoHash: parsed.infoHash,
name: `${show}${ep} [${dl.res ?? "?"}p]`,
sizeBytes: sizeMatch ? Number(sizeMatch[1]) : 0,
seeders: 0,
leechers: 0,
source: "subsplease",
magnet: parsed.magnet,
added: entry.release_date ? new Date(entry.release_date).getTime() / 1000 : undefined,
});
}
return out;
}
export const subsplease: Source = {
id: "subsplease",
label: "SubsPlease",
group: "Anime",
homepage: "https://subsplease.org",
search,
};
+16
View File
@@ -0,0 +1,16 @@
import { promises as fs } from "node:fs";
import parseTorrent from "parse-torrent";
import { buildMagnet, type ParsedMagnet } from "./magnet";
export async function magnetFromTorrentFile(path: string): Promise<ParsedMagnet | null> {
try {
const buf = await fs.readFile(path);
const parsed = await parseTorrent(new Uint8Array(buf));
const infoHash = parsed?.infoHash?.toLowerCase();
if (!infoHash) return null;
const name = parsed.name || infoHash;
return { infoHash, name, magnet: buildMagnet(infoHash, name) };
} catch {
return null;
}
}
+37
View File
@@ -0,0 +1,37 @@
export type SourceId =
| "fitgirl"
| "yts"
| "eztv"
| "nyaa"
| "subsplease"
| "solid"
| "tpb-movies"
| "tpb-tv"
| "x1337-movies"
| "x1337-tv";
export type SourceGroup = "Games" | "Movies" | "TV" | "Anime";
export interface TorrentResult {
infoHash: string;
name: string;
sizeBytes: number;
seeders: number;
leechers: number;
numFiles?: number;
source: SourceId;
magnet: string;
added?: number;
}
export interface SearchOptions {
signal?: AbortSignal;
}
export interface Source {
id: SourceId;
label: string;
group: SourceGroup;
homepage: string;
search(query: string, opts?: SearchOptions): Promise<TorrentResult[]>;
}
+131
View File
@@ -0,0 +1,131 @@
import { fetchResilient, HttpError, USER_AGENT } from "../util/net";
import { unescapeEntities } from "./rss";
import { parseSize } from "../util/format";
import type { SearchOptions, Source, SourceId, TorrentResult } from "./types";
const HOSTS = ["1337x.to", "1337x.st", "x1337x.ws", "1337xx.to"];
const MAX_DETAILS = 8;
const STOP = new Set(["the", "a", "an", "of", "and", "or", "to"]);
interface Row {
name: string;
path: string;
seeders: number;
leechers: number;
sizeBytes: number;
}
function parseRows(html: string): Row[] {
const start = html.indexOf("table-list");
if (start < 0) return [];
const out: Row[] = [];
for (const tr of html.slice(start).split(/<tr[\s>]/i).slice(1)) {
const link = tr.match(/href="(\/torrent\/[^"]+)"[^>]*>([^<]+)<\/a>/i);
if (!link) continue;
const size = tr.match(/class="coll-4 size[^"]*">\s*([\d.]+\s*[KMGT]i?B)/i)?.[1] ?? "";
out.push({
name: unescapeEntities(link[2]!.trim()),
path: link[1]!,
seeders: Number(tr.match(/class="coll-2 seeds[^"]*">\s*(\d+)/i)?.[1] ?? 0),
leechers: Number(tr.match(/class="coll-3 leeches[^"]*">\s*(\d+)/i)?.[1] ?? 0),
sizeBytes: parseSize(size),
});
}
return out;
}
async function fetchText(url: string, opts: SearchOptions, retries: number): Promise<string> {
const res = await fetchResilient(url, {
headers: { "User-Agent": USER_AGENT },
signal: opts.signal,
retries,
});
if (!res.ok) throw new HttpError(res.status, `1337x returned ${res.status}`);
return res.text();
}
async function detailMagnet(base: string, path: string, opts: SearchOptions): Promise<string | null> {
try {
const html = await fetchText(`${base}${path}`, opts, 1);
const raw = html.match(/magnet:\?xt=urn:btih:[^"'<>\s]+/i)?.[0];
return raw ? unescapeEntities(raw) : null;
} catch {
return null;
}
}
async function search(
query: string,
cat: "Movies" | "TV",
source: SourceId,
opts: SearchOptions = {},
): Promise<TorrentResult[]> {
const q = query.trim();
const path = q
? `/category-search/${encodeURIComponent(q).replace(/%20/g, "+")}/${cat}/1/`
: `/popular-${cat === "Movies" ? "movies" : "tv"}`;
let base = "";
let html = "";
let lastError: unknown;
for (const host of HOSTS) {
try {
const candidate = `https://${host}`;
html = await fetchText(`${candidate}${path}`, opts, 2);
base = candidate;
break;
} catch (e) {
if (opts.signal?.aborted) throw e;
lastError = e;
}
}
if (!base) throw lastError instanceof Error ? lastError : new HttpError(0, "1337x unreachable");
const all = parseRows(html);
const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
const meaningful = tokens.filter((t) => !STOP.has(t));
const need = meaningful.length ? meaningful : tokens;
const matched = need.length
? all.filter((r) => {
const n = r.name.toLowerCase();
return need.every((t) => n.includes(t));
})
: all;
matched.sort((a, b) => b.seeders - a.seeders);
const rows = matched.slice(0, MAX_DETAILS);
const settled = await Promise.all(
rows.map(async (row): Promise<TorrentResult | null> => {
const magnet = await detailMagnet(base, row.path, opts);
const infoHash = magnet?.match(/urn:btih:([a-zA-Z0-9]+)/i)?.[1]?.toLowerCase();
if (!magnet || !infoHash) return null;
return {
infoHash,
name: row.name,
sizeBytes: row.sizeBytes,
seeders: row.seeders,
leechers: row.leechers,
source,
magnet,
};
}),
);
return settled.filter((r): r is TorrentResult => r !== null);
}
export const x1337Movies: Source = {
id: "x1337-movies",
label: "1337x",
group: "Movies",
homepage: "https://1337x.to",
search: (query, opts = {}) => search(query, "Movies", "x1337-movies", opts),
};
export const x1337Tv: Source = {
id: "x1337-tv",
label: "1337x",
group: "TV",
homepage: "https://1337x.to",
search: (query, opts = {}) => search(query, "TV", "x1337-tv", opts),
};
+80
View File
@@ -0,0 +1,80 @@
import { fetchResilient, HttpError, USER_AGENT } from "../util/net";
import { buildMagnet } from "./magnet";
import type { SearchOptions, Source, TorrentResult } from "./types";
const HOSTS = ["yts.mx", "yts.am", "yts.rs"];
interface YtsTorrent {
hash?: string;
quality?: string;
type?: string;
size_bytes?: number;
seeds?: number;
peers?: number;
}
interface YtsMovie {
title_long?: string;
title?: string;
date_uploaded_unix?: number;
torrents?: YtsTorrent[];
}
interface YtsResponse {
data?: { movies?: YtsMovie[] };
}
async function fetchMovies(params: URLSearchParams, opts: SearchOptions): Promise<YtsResponse> {
let lastError: unknown;
for (const host of HOSTS) {
try {
const res = await fetchResilient(`https://${host}/api/v2/list_movies.json?${params.toString()}`, {
headers: { "User-Agent": USER_AGENT },
signal: opts.signal,
retries: 1,
});
if (res.ok) return (await res.json()) as YtsResponse;
lastError = new HttpError(res.status, `YTS returned ${res.status}`);
} catch (e) {
if (opts.signal?.aborted) throw e;
lastError = e;
}
}
throw lastError instanceof Error ? lastError : new HttpError(0, "YTS unreachable");
}
async function search(query: string, opts: SearchOptions = {}): Promise<TorrentResult[]> {
const q = query.trim();
const params = new URLSearchParams({ limit: "50" });
if (q) params.set("query_term", q);
else params.set("sort_by", "date_added");
const json = await fetchMovies(params, opts);
const out: TorrentResult[] = [];
for (const movie of json.data?.movies ?? []) {
const base = movie.title_long || movie.title || "Unknown";
for (const t of movie.torrents ?? []) {
if (!t.hash) continue;
const infoHash = t.hash.toLowerCase();
const tag = [t.quality, t.type].filter(Boolean).join(" ");
const name = tag ? `${base} [${tag}]` : base;
out.push({
infoHash,
name,
sizeBytes: t.size_bytes ?? 0,
seeders: t.seeds ?? 0,
leechers: t.peers ?? 0,
source: "yts",
magnet: buildMagnet(infoHash, name),
added: movie.date_uploaded_unix,
});
}
}
return out;
}
export const yts: Source = {
id: "yts",
label: "YTS",
group: "Movies",
homepage: "https://yts.mx",
search,
};
+366
View File
@@ -0,0 +1,366 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Text, useApp, useInput, useStdout, useStdin } from "ink";
import { promises as fs } from "node:fs";
import { loadConfig, saveConfig, type Config } from "../config/config";
import { DownloadQueue } from "../download/queue";
import { loadQueue } from "../download/persist";
import { loadHistory } from "../download/history";
import { reconcileQueue } from "../download/reconcile";
import { parseMagnet } from "../sources/magnet";
import { magnetFromTorrentFile } from "../sources/torrentFile";
import { readClipboard } from "../util/clipboard";
import { cleanText, truncate } from "../util/format";
import {
StoreContext,
type CaptureMode,
type DownloadFocus,
type Region,
type Section,
type Store,
type View,
} from "./store";
import { Logo } from "./components/Logo";
import { Sidebar, RAIL_WIDTH } from "./components/Sidebar";
import { Rule } from "./components/Rule";
import { Footer } from "./components/Footer";
import { HelpOverlay } from "./components/HelpOverlay";
import { Results } from "./components/Results";
import { Downloads } from "./components/Downloads";
import { Spinner } from "./components/Spinner";
import { TabTitle } from "./components/TabTitle";
import { Splash } from "./views/Splash";
import { footerHints } from "./keymap";
import { COLOR, ICON } from "./theme";
import { useMouseWheel } from "./hooks/useMouseWheel";
import type { SourceId } from "../sources/types";
export function App({
initialMagnet,
initialTorrent,
onQuit,
}: { initialMagnet?: string; initialTorrent?: string; onQuit?: () => void } = {}) {
useMouseWheel();
const { exit } = useApp();
const { isRawModeSupported } = useStdin();
const { stdout } = useStdout();
const [size, setSize] = useState({
rows: stdout?.rows ?? 24,
cols: stdout?.columns ?? 80,
});
useEffect(() => {
if (!stdout) return;
let last = { rows: stdout.rows ?? 24, cols: stdout.columns ?? 80 };
const onResize = (): void => {
const next = { rows: stdout.rows ?? 24, cols: stdout.columns ?? 80 };
if (next.rows === last.rows && next.cols === last.cols) return;
if (next.rows < last.rows || next.cols < last.cols) {
stdout.write("\x1b[2J\x1b[H");
}
last = next;
setSize(next);
};
stdout.on("resize", onResize);
return () => {
stdout.off("resize", onResize);
};
}, [stdout]);
const rows = size.rows;
const cols = size.cols;
const [queue, setQueue] = useState<DownloadQueue | null>(null);
const [config, setConfigState] = useState<Config | null>(null);
const [view, setView] = useState<View>("splash");
const [query, setQuery] = useState("");
const [section, setSection] = useState<Section>("all");
const [region, setRegion] = useState<Region>("content");
const [captureMode, setCaptureMode] = useState<CaptureMode>("none");
const [downloadFocus, setDownloadFocus] = useState<DownloadFocus | null>(null);
const [showHelp, setShowHelp] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const booting = useRef(false);
useEffect(() => {
if (booting.current) return;
booting.current = true;
let alive = true;
void (async () => {
const cfg = await loadConfig();
const q = new DownloadQueue();
q.restore(reconcileQueue(await loadQueue()));
q.restoreHistory(await loadHistory());
if (!alive) {
q.suspend();
return;
}
setConfigState(cfg);
setQueue(q);
const launch = initialMagnet
? parseMagnet(initialMagnet)
: initialTorrent
? await magnetFromTorrentFile(initialTorrent)
: null;
if (launch) {
await fs.mkdir(cfg.downloadDir, { recursive: true }).catch(() => {});
q.add(
{ id: launch.infoHash, name: launch.name, magnet: launch.magnet },
cfg.downloadDir,
);
setView("browser");
setSection("downloads");
setRegion("content");
}
})();
return () => {
alive = false;
};
}, [initialMagnet, initialTorrent]);
useEffect(() => {
if (!queue) return;
const onCompleted = (name: string): void =>
setNotice(`${ICON.done} ${truncate(cleanText(name), 40)}`);
queue.on("completed", onCompleted);
return () => {
queue.off("completed", onCompleted);
};
}, [queue]);
useEffect(
() => () => {
queue?.suspend();
},
[queue],
);
const quitAll = useCallback(() => {
queue?.suspend();
if (onQuit) onQuit();
else exit();
}, [queue, onQuit, exit]);
const setConfig = useCallback((c: Config) => {
setConfigState(c);
void saveConfig(c);
}, []);
const startDownload = useCallback(
(input: {
id: string;
name: string;
magnet: string;
source?: SourceId;
sizeBytes?: number;
}) => {
if (!config || !queue) return;
void fs.mkdir(config.downloadDir, { recursive: true }).catch(() => {});
queue.add(input, config.downloadDir);
setNotice(`Added: ${truncate(cleanText(input.name), 40)}`);
setSection("downloads");
setRegion("content");
},
[config, queue],
);
const submitQuery = useCallback(
(raw: string) => {
const q = raw.trim();
if (q) {
const magnet = parseMagnet(q);
if (magnet) {
startDownload({
id: magnet.infoHash,
name: magnet.name,
magnet: magnet.magnet,
});
setView("browser");
return;
}
}
setQuery(q);
setView("browser");
if (section === "downloads") setSection("all");
setRegion("content");
},
[section, startDownload],
);
const pasteFromClipboard = useCallback(async () => {
const text = (await readClipboard()).trim();
if (!text) {
setNotice("Clipboard is empty.");
return;
}
const found = text.match(/magnet:\?xt=urn:btih:[^\s"'<>]+/i)?.[0];
const magnet = found ? parseMagnet(found) : null;
if (magnet) {
startDownload({ id: magnet.infoHash, name: magnet.name, magnet: magnet.magnet });
setView("browser");
return;
}
setNotice("No magnet link on the clipboard.");
}, [startDownload]);
useEffect(() => {
if (!notice) return;
const t = setTimeout(() => setNotice(null), 4000);
return () => clearTimeout(t);
}, [notice]);
const compact = rows < 18;
const showTopRule = !compact;
const showFooter = rows >= 12;
const chrome =
3 +
(showTopRule ? 1 : 0) +
(compact ? 0 : 1) +
(showFooter ? 1 : 0);
const bodyH = Math.max(6, rows - 1 - chrome);
const listRows = Math.max(4, bodyH);
const contentWidth = Math.max(24, cols - RAIL_WIDTH - 3);
const ruleWidth = Math.max(10, cols - 2);
const store: Store | null = useMemo(() => {
if (!queue || !config) return null;
return {
config,
setConfig,
queue,
view,
setView,
query,
submitQuery,
section,
setSection,
region: showHelp ? "help" : region,
setRegion,
captureMode,
setCaptureMode,
downloadFocus,
setDownloadFocus,
startDownload,
notice,
setNotice,
quitAll,
listRows,
compact,
contentWidth,
cols,
rows,
};
}, [
queue,
config,
view,
query,
submitQuery,
section,
region,
showHelp,
captureMode,
downloadFocus,
startDownload,
notice,
listRows,
compact,
contentWidth,
cols,
rows,
setConfig,
quitAll,
]);
useInput(
(input, key) => {
if (key.ctrl && input === "c") {
quitAll();
return;
}
if (captureMode === "text") return;
if (showHelp) {
setShowHelp(false);
return;
}
if (input === "?") {
setShowHelp(true);
return;
}
if (input === "m") {
void pasteFromClipboard();
return;
}
if (key.tab) {
setRegion(region === "sidebar" ? "content" : "sidebar");
return;
}
if (key.escape) {
if (captureMode === "esc") return;
if (region === "content") {
setRegion("sidebar");
return;
}
setView("splash");
return;
}
if (input === "q") {
quitAll();
return;
}
},
{ isActive: isRawModeSupported && view === "browser" && !!store },
);
if (!store) {
return (
<Box height={rows} justifyContent="center" alignItems="center">
<Spinner label="Starting torlink" />
</Box>
);
}
if (view === "splash") {
return (
<StoreContext.Provider value={store}>
<TabTitle />
<Splash />
</StoreContext.Provider>
);
}
return (
<StoreContext.Provider value={store}>
<TabTitle />
<Box flexDirection="column" paddingX={1}>
<Box justifyContent="space-between">
<Logo />
{notice ? <Text color={COLOR.good}>{notice}</Text> : null}
</Box>
{showTopRule ? <Rule width={ruleWidth} /> : null}
{showHelp ? (
<Box marginTop={1}>
<HelpOverlay />
</Box>
) : null}
<Box
height={bodyH}
marginTop={compact ? 0 : 1}
display={showHelp ? "none" : "flex"}
overflow="hidden"
>
<Sidebar />
<Box flexGrow={1} flexDirection="column">
{section === "downloads" ? <Downloads /> : <Results />}
</Box>
</Box>
{showFooter ? (
<Box display={showHelp ? "none" : "flex"}>
<Footer hints={footerHints(region, section, downloadFocus)} />
</Box>
) : null}
</Box>
</StoreContext.Provider>
);
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useState } from "react";
import { Box, Text, useInput } from "ink";
import { useStore, useQueueItems, useQueueHistory, type DownloadFocus } from "../store";
import { Panel } from "./Panel";
import { ProgressBar } from "./ProgressBar";
import { wrapStep, windowStart } from "../move";
import { COLOR, GUTTER, ICON, SOURCE_STYLE } from "../theme";
import {
cleanText,
formatBytes,
formatBytesPerSec,
formatEtaShort,
formatRelative,
truncate,
} from "../../util/format";
import type { QueueItem } from "../../download/types";
import type { HistoryItem } from "../../download/history";
const ROWS_PER_ACTIVE = 2;
const MARK = 2;
const PAUSED = "#7c7785";
function statusColor(status: QueueItem["status"]): string {
if (status === "failed") return COLOR.bad;
if (status === "paused") return PAUSED;
return COLOR.accent;
}
function statusIcon(status: QueueItem["status"]): string {
if (status === "failed") return ICON.error;
if (status === "paused") return ICON.pause;
return ICON.down;
}
function rightStats(it: QueueItem): string {
if (it.status === "downloading") {
const speed = formatBytesPerSec(it.speed) || "…";
const eta = it.eta ? ` ${formatEtaShort(it.eta)}` : "";
return `${it.progress}% ${speed} ${ICON.peer}${it.peers}${eta}`;
}
if (it.status === "paused") return `paused ${it.progress}%`;
return truncate(it.error || "failed", 28);
}
export function Downloads() {
const { queue, region, contentWidth, listRows, startDownload, setDownloadFocus } = useStore();
const active = useQueueItems(queue);
const recent = useQueueHistory(queue);
const focused = region === "content";
const total = active.length + recent.length;
const [cursor, setCursor] = useState(0);
const clamped = Math.min(cursor, Math.max(0, total - 1));
const inActive = clamped < active.length;
const recentCursor = clamped - active.length;
useInput(
(input, key) => {
if (key.upArrow) setCursor(wrapStep(clamped, -1, total));
else if (key.downArrow) setCursor(wrapStep(clamped, 1, total));
else if (input === "f") queue.retryFailed();
else if (input === "x") queue.clearHistory();
else if (inActive) {
const it = active[clamped];
if (!it) return;
if (input === "c") queue.cancel(it.id);
else if (input === "p") queue.togglePause(it.id);
} else {
const h = recent[recentCursor];
if (!h) return;
if (key.return || input === "d")
startDownload({
id: h.id,
name: h.name,
magnet: h.magnet,
source: h.source,
sizeBytes: h.sizeBytes,
});
else if (input === "c") queue.removeHistory(h.id);
}
},
{ isActive: focused && total > 0 },
);
let focusKind: DownloadFocus | null = null;
if (focused && total > 0) {
if (!inActive) focusKind = "recent";
else {
const st = active[clamped]?.status;
if (st === "downloading" || st === "paused" || st === "failed") focusKind = st;
}
}
useEffect(() => {
setDownloadFocus(focusKind);
return () => setDownloadFocus(null);
}, [focusKind, setDownloadFocus]);
const panelH = Math.max(5, listRows - 1);
if (total === 0) {
return (
<Panel title="downloads" width={contentWidth} focused={focused} height={panelH}>
<Text dimColor>No downloads yet. Find something and press d to grab it.</Text>
</Panel>
);
}
const hasActive = active.length > 0;
const hasRecent = recent.length > 0;
const headerRows = hasRecent ? 1 : 0;
const ceiling = Math.max(1, panelH - 1);
let gapRows = hasActive && hasRecent ? 1 : 0;
let maxActive = 0;
let maxRecent = 0;
if (!hasRecent) {
maxActive = Math.max(1, Math.floor(ceiling / ROWS_PER_ACTIVE));
} else if (!hasActive) {
maxRecent = Math.max(1, ceiling - headerRows);
} else {
let budget = ceiling - headerRows - gapRows;
if (budget < ROWS_PER_ACTIVE + 1) {
gapRows = 0;
budget = ceiling - headerRows;
}
const activeRowCap = Math.max(ROWS_PER_ACTIVE, Math.floor(budget * 0.55));
maxActive = Math.min(active.length, Math.max(1, Math.floor(activeRowCap / ROWS_PER_ACTIVE)));
maxRecent = Math.max(1, budget - maxActive * ROWS_PER_ACTIVE);
}
const activeStart = windowStart(inActive ? clamped : 0, active.length, maxActive);
const activeVisible = active.slice(activeStart, activeStart + maxActive);
const recentStart = windowStart(inActive ? 0 : recentCursor, recent.length, maxRecent);
const recentVisible = recent.slice(recentStart, recentStart + maxRecent);
const inner = contentWidth - 4;
const gap = 2;
const barW = Math.max(8, Math.min(28, Math.floor(inner * 0.4)));
const statsW = Math.max(6, inner - MARK - GUTTER - barW - gap);
const count = hasActive ? `(${active.length})` : undefined;
return (
<Panel title="downloads" width={contentWidth} focused={focused} count={count} height={panelH}>
{activeVisible.map((it, i) => {
const here = activeStart + i === clamped && focused && inActive;
const sc = statusColor(it.status);
const ss = SOURCE_STYLE[it.source ?? "fitgirl"];
return (
<Box key={it.id} flexDirection="column">
<Box>
<Box width={MARK} flexShrink={0}>
<Text color={COLOR.accent} bold>
{here ? ICON.pointer : ""}
</Text>
</Box>
<Box width={GUTTER} flexShrink={0}>
<Text color={sc}>{statusIcon(it.status)}</Text>
</Box>
<Box flexGrow={1} minWidth={0}>
<Text
wrap="truncate-end"
bold={here}
color={here ? COLOR.accent : undefined}
dimColor={!here}
>
{cleanText(it.name)}
</Text>
</Box>
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text dimColor>{it.totalBytes > 0 ? formatBytes(it.totalBytes) : "-"}</Text>
</Box>
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text color={it.source ? ss.color : undefined} dimColor={!it.source || !here}>
{it.source ? ss.tag : "mag"}
</Text>
</Box>
</Box>
<Box>
<Box width={MARK + GUTTER} flexShrink={0} />
<ProgressBar
pct={it.progress}
width={barW}
color={sc}
animate={it.status === "downloading"}
/>
<Box marginLeft={gap} flexShrink={0}>
<Text dimColor>{truncate(rightStats(it), statsW)}</Text>
</Box>
</Box>
</Box>
);
})}
{hasRecent ? (
<Box marginTop={gapRows ? 1 : 0}>
<Text dimColor>{`Recently downloaded${recent.length > 1 ? ` (${recent.length})` : ""}`}</Text>
</Box>
) : null}
{recentVisible.map((h: HistoryItem, i) => {
const here = recentStart + i === recentCursor && focused && !inActive;
const ss = SOURCE_STYLE[h.source ?? "fitgirl"];
const when = formatRelative(h.completedAt / 1000);
return (
<Box key={h.id}>
<Box width={MARK} flexShrink={0}>
<Text color={COLOR.accent} bold>
{here ? ICON.pointer : ""}
</Text>
</Box>
<Box width={GUTTER} flexShrink={0}>
<Text color={COLOR.good} dimColor={!here}>
{ICON.done}
</Text>
</Box>
<Box flexGrow={1} minWidth={0}>
<Text
wrap="truncate-end"
bold={here}
color={here ? COLOR.accent : undefined}
dimColor={!here}
>
{cleanText(h.name)}
</Text>
</Box>
<Box width={10} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text dimColor>{h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-"}</Text>
</Box>
<Box width={12} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text dimColor>{when || "-"}</Text>
</Box>
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text color={h.source ? ss.color : undefined} dimColor={!h.source || !here}>
{h.source ? ss.tag : "mag"}
</Text>
</Box>
</Box>
);
})}
</Panel>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Box, Text } from "ink";
import { COLOR } from "../theme";
import type { Hint } from "../keymap";
export function Footer({ hints }: { hints: Hint[] }) {
return (
<Box>
<Text>
{hints.map((h, i) => (
<Text key={h.keys + h.label}>
{i > 0 ? <Text dimColor>{" "}</Text> : null}
<Text color={COLOR.alt}>{h.keys}</Text>
<Text dimColor>{` ${h.label}`}</Text>
</Text>
))}
</Text>
</Box>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { Box, Text } from "ink";
import { HELP_GROUPS } from "../keymap";
import { useStore } from "../store";
import { COLOR, RULE, lerpHex } from "../theme";
const CARD_BORDER = lerpHex(COLOR.accent, RULE, 0.55);
const KEY_GAP = 2;
const COL_GAP = 2;
const KEY_W = HELP_GROUPS.map(
(g) => Math.max(...g.hints.map((h) => h.keys.length)) + KEY_GAP,
);
const COL_W = HELP_GROUPS.map(
(g, i) => KEY_W[i]! + Math.max(...g.hints.map((h) => h.label.length)),
);
const CARD_W =
COL_W.reduce((a, b) => a + b, 0) + (HELP_GROUPS.length - 1) * COL_GAP + 4;
const KEY_W_STACKED = Math.max(...KEY_W);
export function HelpOverlay() {
const { cols } = useStore();
const columns = cols >= CARD_W;
return (
<Box
flexDirection="column"
alignSelf="flex-start"
borderStyle="round"
borderColor={CARD_BORDER}
paddingX={columns ? 1 : 2}
paddingY={1}
>
<Text bold color={COLOR.accent}>
Keyboard
</Text>
<Box marginTop={1} flexDirection={columns ? "row" : "column"}>
{HELP_GROUPS.map((group, gi) => (
<Box
key={group.title}
flexDirection="column"
width={columns ? COL_W[gi] : undefined}
marginRight={columns && gi < HELP_GROUPS.length - 1 ? COL_GAP : 0}
marginTop={!columns && gi > 0 ? 1 : 0}
>
<Text bold>{group.title}</Text>
{group.hints.map((h) => (
<Box key={h.keys + h.label}>
<Box width={columns ? KEY_W[gi] : KEY_W_STACKED} flexShrink={0}>
<Text color={COLOR.alt}>{h.keys}</Text>
</Box>
<Text dimColor>{h.label}</Text>
</Box>
))}
</Box>
))}
</Box>
<Box marginTop={1}>
<Text dimColor>Press ? or esc to close</Text>
</Box>
</Box>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { Box, Text } from "ink";
import { LOGO_LINES, SPROUT_CELLS } from "../logo";
import { COLOR, lerpHex } from "../theme";
const HIGHLIGHT = "#ffffff";
const TOP = COLOR.bright;
const BASE = "#7c5cd6";
const SHADE = "#4c3a8a";
const SPROUT_COLOR = "#5ae87a";
function getSheen(t: number): string {
if (t < 0.15) return lerpHex(HIGHLIGHT, TOP, t / 0.15);
if (t < 0.4) return lerpHex(TOP, COLOR.accent, (t - 0.15) / 0.25);
if (t < 0.7) return lerpHex(COLOR.accent, BASE, (t - 0.4) / 0.3);
return lerpHex(BASE, SHADE, (t - 0.7) / 0.3);
}
export function Logo() {
const rows = LOGO_LINES.length;
return (
<Box flexDirection="column">
{LOGO_LINES.map((line, row) => {
const textRow = Math.max(0, row - 1);
const textRows = Math.max(1, rows - 1);
const tY = textRow / (textRows - 1 || 1);
const chars = [...line];
const last = Math.max(1, chars.length - 1);
return (
<Box key={row}>
{chars.map((ch, i) => {
if (ch === " ") return <Text key={i}> </Text>;
if (SPROUT_CELLS.has(`${row},${i}`)) {
return (
<Text key={i} bold color={SPROUT_COLOR}>
{ch}
</Text>
);
}
const tX = i / last;
const factor = (tX + tY) / 2;
return (
<Text key={i} bold color={getSheen(factor)}>
{ch}
</Text>
);
})}
</Box>
);
})}
</Box>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from "react";
import { Box, Text } from "ink";
import { COLOR, RULE } from "../theme";
interface PanelProps {
title: string;
width: number;
focused?: boolean;
count?: string;
height?: number;
children: ReactNode;
}
export function Panel({ title, width, focused, count, height, children }: PanelProps) {
const color = focused ? COLOR.accent : RULE;
const w = Math.max(10, width);
const cap = title.charAt(0).toUpperCase() + title.slice(1);
const label = count ? `${cap} ${count}` : cap;
const fill = Math.max(0, w - 5 - label.length);
return (
<Box flexDirection="column" width={w}>
<Box>
<Text color={color}>{"╭─ "}</Text>
<Text bold color={color}>
{label}
</Text>
<Text color={color}>{` ${"─".repeat(fill)}`}</Text>
</Box>
<Box
width={w}
height={height}
flexGrow={height ? 0 : 1}
flexDirection="column"
borderStyle="round"
borderTop={false}
borderColor={color}
paddingX={1}
overflow="hidden"
>
{children}
</Box>
</Box>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { Text } from "ink";
import { COLOR, RULE, lerpHex } from "../theme";
const DEEP = "#7c5cd6";
const SHEEN_PEAK = "#f4efff";
const RADIUS = 3.5;
const GAP = 8;
const MAX_SHEEN = 0.9;
const TICK_MS = 90;
interface Run {
color: string;
len: number;
}
function ramp(t: number, deep: string, mid: string, bright: string): string {
return t <= 0.5 ? lerpHex(deep, mid, t / 0.5) : lerpHex(mid, bright, (t - 0.5) / 0.5);
}
function runs(colors: string[]): Run[] {
const out: Run[] = [];
for (const c of colors) {
const prev = out[out.length - 1];
if (prev && prev.color === c) prev.len++;
else out.push({ color: c, len: 1 });
}
return out;
}
function paint(rs: Run[]) {
return rs.map((r, i) => (
<Text key={i} color={r.color}>
{"█".repeat(r.len)}
</Text>
));
}
export function ProgressBar({
pct,
width,
color = COLOR.accent,
animate = false,
}: {
pct: number;
width: number;
color?: string;
animate?: boolean;
}) {
const clamped = Math.max(0, Math.min(100, pct));
const filled = Math.round((clamped / 100) * width);
const empty = Math.max(0, width - filled);
const denom = Math.max(1, width - 1);
const [tick, setTick] = useState(0);
useEffect(() => {
if (!animate) return;
const timer = setInterval(() => setTick((v) => v + 1), TICK_MS);
timer.unref?.();
return () => clearInterval(timer);
}, [animate]);
const track = empty > 0 ? <Text color={RULE}>{"░".repeat(empty)}</Text> : null;
if (filled === 0) return <Text>{track}</Text>;
if (!animate) {
const deep = lerpHex(color, "#000000", 0.3);
const bright = lerpHex(color, COLOR.text, 0.35);
const cells = Array.from({ length: filled }, (_, i) => ramp(i / denom, deep, color, bright));
return (
<Text>
{paint(runs(cells))}
{track}
</Text>
);
}
const period = Math.ceil(width + RADIUS * 2) + GAP;
const center = (tick % period) - RADIUS;
const cells = Array.from({ length: filled }, (_, i) => {
let c = ramp(i / denom, DEEP, COLOR.accent, COLOR.bright);
const d = Math.abs(i - center);
if (d < RADIUS) {
const intensity = 0.5 * (1 + Math.cos((Math.PI * d) / RADIUS)) * MAX_SHEEN;
c = lerpHex(c, SHEEN_PEAK, intensity);
}
return c;
});
return (
<Text>
{paint(runs(cells))}
{track}
</Text>
);
}
+370
View File
@@ -0,0 +1,370 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Box, Text, useInput } from "ink";
import { useStore, CATEGORIES } from "../store";
import { Spinner } from "./Spinner";
import { SearchBar } from "./SearchBar";
import { Panel } from "./Panel";
import { Rule } from "./Rule";
import { useConcurrentSearch } from "../hooks/useConcurrentSearch";
import { getSource, SOURCES } from "../../sources/registry";
import { wrapStep, windowStart } from "../move";
import { COLOR, GUTTER, ICON, SOURCE_STYLE } from "../theme";
import { cleanText, formatBytes, formatRelative, truncate } from "../../util/format";
import type { Source, TorrentResult } from "../../sources/types";
type Mode = "list" | "search" | "detail";
const PLACEHOLDER = "Search, or paste a magnet…";
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
return (
<Box>
<Box width={9} flexShrink={0}>
<Text dimColor>{label}</Text>
</Box>
<Box flexGrow={1} minWidth={0}>{value}</Box>
</Box>
);
}
function Detail({ r, width }: { r: TorrentResult; width: number }) {
const ss = SOURCE_STYLE[r.source];
const date = formatRelative(r.added);
const health =
r.seeders || r.leechers ? (
<Text>
<Text color={r.seeders > 0 ? COLOR.good : undefined} bold={r.seeders > 0}>
{r.seeders}
</Text>
<Text dimColor>{` seeders ${ICON.dot} ${r.leechers} leechers`}</Text>
</Text>
) : (
<Text dimColor>unknown</Text>
);
return (
<Box flexDirection="column">
<Box>
<Box flexGrow={1} minWidth={0}>
<Text bold color={COLOR.text} wrap="truncate-end">
{cleanText(r.name)}
</Text>
</Box>
<Box flexShrink={0} marginLeft={2}>
<Text color={ss.color} bold>
{ss.tag}
</Text>
</Box>
</Box>
<Rule width={width} />
<Box marginTop={1} flexDirection="column">
<DetailRow
label="Size"
value={
r.sizeBytes > 0 ? (
<Text color={COLOR.text}>{formatBytes(r.sizeBytes)}</Text>
) : (
<Text dimColor>unknown</Text>
)
}
/>
<DetailRow label="Health" value={health} />
{r.numFiles ? (
<DetailRow label="Files" value={<Text dimColor>{String(r.numFiles)}</Text>} />
) : null}
{date ? <DetailRow label="Added" value={<Text dimColor>{date}</Text>} /> : null}
<DetailRow
label="Hash"
value={
<Text color={COLOR.alt} dimColor wrap="truncate-end">
{r.infoHash}
</Text>
}
/>
</Box>
<Box marginTop={1}>
<Text color={COLOR.accent} bold>
d
</Text>
<Text color={COLOR.text}> Download</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.alt}>esc</Text>
<Text dimColor> back</Text>
</Box>
</Box>
);
}
export function Results() {
const {
query,
submitQuery,
section,
region,
setCaptureMode,
startDownload,
contentWidth,
listRows,
} = useStore();
const search = useConcurrentSearch(query);
const results = useMemo(() => {
const cat = CATEGORIES.find((c) => c.key === section);
if (!cat?.group) return search.results;
return search.results.filter((r) => getSource(r.source).group === cat.group);
}, [search.results, section]);
const focused = region === "content";
const [mode, setMode] = useState<Mode>("list");
const [cursor, setCursor] = useState(0);
const [detail, setDetail] = useState<TorrentResult | null>(null);
useEffect(() => {
setCursor(0);
}, [results]);
useEffect(() => {
if (!focused) return;
setCaptureMode(mode === "search" ? "text" : mode === "detail" ? "esc" : "none");
return () => setCaptureMode("none");
}, [mode, focused, setCaptureMode]);
useEffect(() => {
if (!focused) setMode("list");
}, [focused]);
const clamped = Math.min(cursor, Math.max(0, results.length - 1));
const searchH = 3;
const panelOuter = Math.max(5, listRows - searchH - 1);
const listHeight = Math.max(3, panelOuter - 4);
const pageJump = Math.max(1, listHeight - 1);
const openDownload = (r: TorrentResult): void =>
startDownload({
id: r.infoHash,
name: r.name,
magnet: r.magnet,
source: r.source,
sizeBytes: r.sizeBytes,
});
useInput(
(input, key) => {
if (input === "/") {
setMode("search");
return;
}
if (results.length === 0) return;
if (key.upArrow) setCursor(wrapStep(clamped, -1, results.length));
else if (key.downArrow) setCursor(wrapStep(clamped, 1, results.length));
else if (key.pageUp) setCursor(Math.max(0, clamped - pageJump));
else if (key.pageDown) setCursor(Math.min(results.length - 1, clamped + pageJump));
else if (key.return) {
const r = results[clamped];
if (r) {
setDetail(r);
setMode("detail");
}
} else if (input === "d") {
const r = results[clamped];
if (r) openDownload(r);
}
},
{ isActive: focused && mode === "list" },
);
useInput(
(input, key) => {
if (key.escape) {
setMode("list");
setDetail(null);
} else if (input === "d" && detail) openDownload(detail);
},
{ isActive: focused && mode === "detail" },
);
useInput(
(_input, key) => {
if (key.escape) setMode("list");
},
{ isActive: focused && mode === "search" },
);
const onSubmit = (value: string): void => {
setMode("list");
submitQuery(value);
};
const browsing = query.trim() === "";
const erroredCount = useMemo(
() => Object.values(search.perSource).filter((s) => s.error).length,
[search.perSource],
);
const activeCat = CATEGORIES.find((c) => c.key === section);
const tabSources = activeCat?.group ? SOURCES.filter((s) => s.group === activeCat.group) : SOURCES;
const tabErrored =
tabSources.length > 0 && tabSources.every((s) => search.perSource[s.id]?.error);
const showStats = useMemo(
() => results.some((r) => r.sizeBytes > 0 || r.seeders > 0),
[results],
);
const numW = Math.max(2, String(results.length).length);
const outageCodes = (sources: readonly Source[]): string => {
const codes = [
...new Set(sources.map((s) => search.perSource[s.id]?.code).filter(Boolean)),
];
return codes.length ? ` (${codes.join(", ")})` : "";
};
const status = () => {
if (search.loading) {
if (results.length > 0)
return <Text dimColor>{`searching… ${search.done}/${search.total} sources`}</Text>;
return (
<Spinner label={`${browsing ? "Loading" : "Searching"} ${search.done}/${search.total} sources`} />
);
}
if (results.length === 0) {
if (erroredCount >= search.total) {
const downAll = SOURCES.filter((s) => search.perSource[s.id]?.error);
return (
<Text color={COLOR.warn}>
{`Couldn't reach any source. They may be down${outageCodes(downAll)}.`}
</Text>
);
}
if (tabErrored && activeCat) {
const down = tabSources.filter((s) => search.perSource[s.id]?.error);
const who = down.length === 1 ? "The source" : `All ${down.length} sources`;
return (
<Text color={COLOR.warn}>
{`Couldn't reach ${activeCat.label}. ${who} may be down${outageCodes(down)}.`}
</Text>
);
}
if (search.results.length > 0 && activeCat?.group)
return <Text dimColor>{`No ${activeCat.label.toLowerCase()} results yet. Try another tab or a search.`}</Text>;
return (
<Text dimColor>
{browsing ? "Nothing new right now." : `No results for "${truncate(query, 28)}".`}
</Text>
);
}
const note = erroredCount > 0 ? ` (${erroredCount} source${erroredCount === 1 ? "" : "s"} down)` : "";
const head = browsing
? "newest across all sources"
: `${results.length} result${results.length === 1 ? "" : "s"}`;
return <Text dimColor>{head + note}</Text>;
};
const start = windowStart(clamped, results.length, listHeight);
const visible = results.slice(start, start + listHeight);
const count = results.length > 0 ? `(${results.length})` : undefined;
return (
<Box flexDirection="column">
<SearchBar
width={contentWidth}
value={query}
editing={mode === "search"}
placeholder={PLACEHOLDER}
onSubmit={onSubmit}
/>
<Box marginTop={1}>
<Panel
title={mode === "detail" ? "details" : browsing ? "latest" : "results"}
width={contentWidth}
focused={focused}
count={mode === "detail" ? undefined : count}
height={panelOuter}
>
{mode === "detail" && detail ? (
<Detail r={detail} width={Math.max(10, contentWidth - 4)} />
) : (
<>
<Box>{status()}</Box>
<Box flexDirection="column" marginTop={results.length > 0 ? 1 : 0}>
{results.length > 0 ? (
<Box>
<Box width={GUTTER} 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>
{showStats ? (
<>
<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={12} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text bold dimColor>Added</Text>
</Box>
)}
<Box width={4} flexShrink={0} marginLeft={1} justifyContent="flex-end">
<Text bold dimColor>Src</Text>
</Box>
</Box>
) : null}
{visible.map((r, i) => {
const index = start + i;
const here = index === clamped && focused && mode === "list";
const ss = SOURCE_STYLE[r.source];
return (
<Box key={r.infoHash}>
<Box width={GUTTER} flexShrink={0}>
<Text color={COLOR.accent}>{here ? ICON.pointer : ""}</Text>
</Box>
<Box width={numW} flexShrink={0} justifyContent="flex-end">
<Text dimColor>{index + 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={12} 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>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { Text } from "ink";
import { RULE } from "../theme";
export function Rule({ width }: { width: number }) {
return <Text color={RULE}>{"─".repeat(Math.max(1, width))}</Text>;
}
+44
View File
@@ -0,0 +1,44 @@
import { Box, Text } from "ink";
import { TextField } from "./TextField";
import { Panel } from "./Panel";
import { COLOR, ICON } from "../theme";
interface SearchBarProps {
width: number;
value: string;
placeholder?: string;
editing: boolean;
onSubmit: (value: string) => void;
onChange?: (value: string) => void;
}
export function SearchBar({
width,
value,
placeholder = "Search torrents…",
editing,
onSubmit,
onChange,
}: SearchBarProps) {
return (
<Panel title="search" width={width} focused={editing} height={2}>
<Box>
<Text color={COLOR.accent}>{`${ICON.pointer} `}</Text>
<Box flexGrow={1} minWidth={0}>
{editing ? (
<TextField
defaultValue={value}
placeholder={placeholder}
onSubmit={onSubmit}
onChange={onChange}
/>
) : value ? (
<Text wrap="truncate-end">{value}</Text>
) : (
<Text dimColor>{placeholder}</Text>
)}
</Box>
</Box>
</Panel>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { Box, Text, useInput } from "ink";
import { useStore, useQueueItems, CATEGORIES, type Section } from "../store";
import { wrapStep } from "../move";
import { ACCENT_RAMP, COLOR, GUTTER, ICON, RULE } from "../theme";
interface NavItem {
key: Section;
label: string;
}
const FILTERS: NavItem[] = CATEGORIES.map((c) => ({
key: c.key as Section,
label: c.label,
}));
const LIBRARY: NavItem[] = [{ key: "downloads", label: "Downloads" }];
const GROUPS: NavItem[][] = [FILTERS, LIBRARY];
const NAV: NavItem[] = GROUPS.flat();
const BADGE_W = " (00)".length;
export const RAIL_WIDTH =
GUTTER +
Math.max(...NAV.map((n) => n.label.length + (n.key === "downloads" ? BADGE_W : 0)));
export function Sidebar() {
const { section, setSection, region, setRegion, queue } = useStore();
const focused = region === "sidebar";
const idx = Math.max(0, NAV.findIndex((n) => n.key === section));
useQueueItems(queue);
const active = queue.activeCount;
useInput(
(_input, key) => {
if (key.upArrow) setSection(NAV[wrapStep(idx, -1, NAV.length)]!.key);
else if (key.downArrow) setSection(NAV[wrapStep(idx, 1, NAV.length)]!.key);
else if (key.return) setRegion("content");
},
{ isActive: focused },
);
return (
<Box flexDirection="column" width={RAIL_WIDTH} marginRight={1}>
{GROUPS.map((items, gi) => (
<Box key={gi} flexDirection="column" marginTop={gi > 0 ? 1 : 0}>
{items.map((item) => {
const selected = item.key === section;
return (
<Box key={item.key}>
<Box width={GUTTER} flexShrink={0}>
{selected ? (
<Text color={focused ? ACCENT_RAMP[1] : RULE} bold={focused}>
{ICON.bar}
</Text>
) : null}
</Box>
<Text
color={selected ? (focused ? COLOR.accent : COLOR.alt) : undefined}
dimColor={!selected}
bold={selected && focused}
>
{item.label}
</Text>
{item.key === "downloads" && active > 0 ? (
<Box flexShrink={0}>
<Text dimColor>{` (${active})`}</Text>
</Box>
) : null}
</Box>
);
})}
</Box>
))}
</Box>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
import { Text } from "ink";
import { COLOR } from "../theme";
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
export function Spinner({ label }: { label?: string }) {
const [frame, setFrame] = useState(0);
useEffect(() => {
const timer = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), 80);
timer.unref?.();
return () => clearInterval(timer);
}, []);
return (
<Text>
<Text color={COLOR.accent}>{FRAMES[frame]}</Text>
{label ? <Text dimColor>{` ${label}`}</Text> : null}
</Text>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { useEffect } from "react";
import { useStore, useQueueItems } from "../store";
export function TabTitle() {
const { queue } = useStore();
useQueueItems(queue);
const active = queue.activeCount;
useEffect(() => {
const title = active > 0 ? `${active} · torlink` : "torlink";
process.stdout.write(`\x1b]0;${title}\x07`);
if (process.platform === "win32") process.title = title;
}, [active]);
return null;
}
+139
View File
@@ -0,0 +1,139 @@
import { useState } from "react";
import { Text, useInput } from "ink";
export interface TextFieldProps {
isDisabled?: boolean;
defaultValue?: string;
placeholder?: string;
onChange?: (value: string) => void;
onSubmit?: (value: string) => void;
}
interface Edit {
value: string;
cursor: number;
}
export function deleteBefore(value: string, cursor: number): Edit {
if (cursor === 0) return { value, cursor };
return {
value: value.slice(0, cursor - 1) + value.slice(cursor),
cursor: cursor - 1,
};
}
export function deleteWordBefore(value: string, cursor: number): Edit {
let i = cursor;
while (i > 0 && value[i - 1] === " ") i--;
while (i > 0 && value[i - 1] !== " ") i--;
return { value: value.slice(0, i) + value.slice(cursor), cursor: i };
}
export function killToEnd(value: string, cursor: number): Edit {
return { value: value.slice(0, cursor), cursor };
}
export function insertAt(value: string, cursor: number, text: string): Edit {
return {
value: value.slice(0, cursor) + text + value.slice(cursor),
cursor: cursor + text.length,
};
}
const CURSOR = " ";
export function TextField({
isDisabled = false,
defaultValue = "",
placeholder = "",
onChange,
onSubmit,
}: TextFieldProps) {
const [value, setValue] = useState(defaultValue);
const [cursor, setCursor] = useState(defaultValue.length);
function apply(next: Edit): void {
setValue(next.value);
setCursor(Math.max(0, Math.min(next.value.length, next.cursor)));
if (next.value !== value) onChange?.(next.value);
}
useInput(
(input, key) => {
if (key.upArrow || key.downArrow || key.tab || (key.ctrl && input === "c"))
return;
if (key.return) {
onSubmit?.(value);
return;
}
if (key.ctrl) {
switch (input) {
case "u":
apply({ value: "", cursor: 0 });
return;
case "w":
apply(deleteWordBefore(value, cursor));
return;
case "k":
apply(killToEnd(value, cursor));
return;
case "a":
setCursor(0);
return;
case "e":
setCursor(value.length);
return;
default:
return;
}
}
if (key.leftArrow) {
setCursor(Math.max(0, cursor - 1));
return;
}
if (key.rightArrow) {
setCursor(Math.min(value.length, cursor + 1));
return;
}
if (key.backspace || key.delete) {
apply(deleteBefore(value, cursor));
return;
}
if (key.meta || !input) return;
const text = input.replace(/\x1b?\[<\d+;\d+;\d+[Mm]/g, "");
if (!text) return;
apply(insertAt(value, cursor, text));
},
{ isActive: !isDisabled },
);
if (isDisabled) {
return value ? <Text>{value}</Text> : <Text dimColor>{placeholder}</Text>;
}
if (value.length === 0) {
if (placeholder) {
return (
<Text>
<Text inverse>{placeholder[0]}</Text>
<Text dimColor>{placeholder.slice(1)}</Text>
</Text>
);
}
return <Text inverse>{CURSOR}</Text>;
}
const before = value.slice(0, cursor);
const atChar = value[cursor] ?? CURSOR;
const after = cursor < value.length ? value.slice(cursor + 1) : "";
return (
<Text>
{before}
<Text inverse>{atChar}</Text>
{after}
</Text>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useState } from "react";
import { SOURCES } from "../../sources/registry";
import { cachedSearch } from "../../sources/cache";
import { HttpError } from "../../util/net";
import type { SourceId, TorrentResult } from "../../sources/types";
export interface SourceState {
loading: boolean;
error: string | null;
code: string | null;
count: number;
}
function errorCode(e: unknown, timedOut: boolean): string {
if (timedOut) return "timed out";
if (e instanceof HttpError && e.status > 0) return `HTTP ${e.status}`;
return "no response";
}
export interface ConcurrentSearchState {
results: TorrentResult[];
perSource: Record<SourceId, SourceState>;
loading: boolean;
done: number;
total: number;
}
const PER_SOURCE_TIMEOUT_MS = 25000;
function blankPerSource(loading: boolean): Record<SourceId, SourceState> {
const out = {} as Record<SourceId, SourceState>;
for (const s of SOURCES) out[s.id] = { loading, error: null, code: null, count: 0 };
return out;
}
function dedupe(list: TorrentResult[]): TorrentResult[] {
const byHash = new Map<string, TorrentResult>();
for (const r of list) {
const existing = byHash.get(r.infoHash);
if (!existing || r.seeders > existing.seeders) byHash.set(r.infoHash, r);
}
return [...byHash.values()];
}
function sortResults(list: TorrentResult[]): TorrentResult[] {
return list.sort((a, b) => {
if (b.seeders !== a.seeders) return b.seeders - a.seeders;
return (b.added ?? 0) - (a.added ?? 0);
});
}
function idleState(): ConcurrentSearchState {
return {
results: [],
perSource: blankPerSource(false),
loading: false,
done: 0,
total: SOURCES.length,
};
}
export function useConcurrentSearch(query: string): ConcurrentSearchState {
const [state, setState] = useState<ConcurrentSearchState>(idleState);
useEffect(() => {
const ctrl = new AbortController();
let alive = true;
const collected: TorrentResult[] = [];
const per = blankPerSource(true);
let done = 0;
setState({
results: [],
perSource: { ...per },
loading: true,
done: 0,
total: SOURCES.length,
});
for (const source of SOURCES) {
const sc = new AbortController();
const onAbort = (): void => sc.abort();
ctrl.signal.addEventListener("abort", onAbort);
const timer = setTimeout(() => sc.abort(), PER_SOURCE_TIMEOUT_MS);
cachedSearch(source, query, { signal: sc.signal })
.then((res) => {
if (!alive) return;
collected.push(...res);
per[source.id] = { loading: false, error: null, code: null, count: res.length };
})
.catch((e: unknown) => {
if (!alive || ctrl.signal.aborted) return;
const timedOut = sc.signal.aborted;
per[source.id] = {
loading: false,
error: timedOut ? "timed out" : e instanceof Error ? e.message : String(e),
code: errorCode(e, timedOut),
count: 0,
};
})
.finally(() => {
clearTimeout(timer);
ctrl.signal.removeEventListener("abort", onAbort);
if (!alive) return;
done += 1;
setState({
results: sortResults(dedupe(collected.slice())),
perSource: { ...per },
loading: done < SOURCES.length,
done,
total: SOURCES.length,
});
});
}
return () => {
alive = false;
ctrl.abort();
};
}, [query]);
return state;
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect } from "react";
export function useMouseWheel(): void {
useEffect(() => {
const { stdout, stdin } = process;
stdout.write("\x1b[?1000h\x1b[?1006h");
const handler = (data: Buffer): void => {
const str = data.toString("utf8");
const re = /\x1b\[<(64|65);\d+;\d+[Mm]/g;
let match: RegExpExecArray | null;
while ((match = re.exec(str)) !== null) {
const arrow = match[1] === "64" ? "\x1b[A" : "\x1b[B";
process.nextTick(() => stdin.emit("data", Buffer.from(arrow)));
}
};
stdin.prependListener("data", handler);
return () => {
stdout.write("\x1b[?1000l\x1b[?1006l");
stdin.removeListener("data", handler);
};
}, []);
}
+87
View File
@@ -0,0 +1,87 @@
import type { DownloadFocus, Region, Section } from "./store";
export interface Hint {
keys: string;
label: string;
}
interface HelpGroup {
title: string;
hints: Hint[];
}
export const HELP_GROUPS: HelpGroup[] = [
{
title: "Navigate",
hints: [
{ keys: "↑ ↓", label: "Move" },
{ keys: "↵", label: "Open" },
{ keys: "tab", label: "Switch pane" },
{ keys: "esc", label: "Back" },
{ keys: "q", label: "Quit" },
],
},
{
title: "Search",
hints: [
{ keys: "/", label: "Edit search" },
{ keys: "↵", label: "Run search" },
{ keys: "m", label: "Paste magnet" },
],
},
{
title: "Downloads",
hints: [
{ keys: "p", label: "Pause/resume" },
{ keys: "c", label: "Cancel/remove" },
{ keys: "f", label: "Retry failed" },
{ keys: "d", label: "Download again" },
{ keys: "x", label: "Clear recent" },
],
},
];
const ALWAYS: Hint = { keys: "?", label: "Keys" };
const SWITCH: Hint = { keys: "tab", label: "Switch pane" };
export function footerHints(
region: Region,
section: Section,
downloadFocus?: DownloadFocus | null,
): Hint[] {
if (region === "sidebar") {
return [
{ keys: "↑↓", label: "Move" },
{ keys: "↵", label: "Open" },
SWITCH,
ALWAYS,
{ keys: "q", label: "Quit" },
];
}
if (section === "downloads") {
if (downloadFocus === "paused") {
return [{ keys: "p", label: "Resume" }, { keys: "c", label: "Cancel" }, SWITCH, ALWAYS];
}
if (downloadFocus === "failed") {
return [{ keys: "f", label: "Retry" }, { keys: "c", label: "Remove" }, SWITCH, ALWAYS];
}
if (downloadFocus === "recent") {
return [
{ keys: "d", label: "Download again" },
{ keys: "c", label: "Remove" },
{ keys: "x", label: "Clear" },
SWITCH,
ALWAYS,
];
}
return [{ keys: "p", label: "Pause" }, { keys: "c", label: "Cancel" }, SWITCH, ALWAYS];
}
return [
{ keys: "d", label: "Download" },
{ keys: "/", label: "Search" },
{ keys: "m", label: "Paste magnet" },
SWITCH,
ALWAYS,
];
}
+9
View File
@@ -0,0 +1,9 @@
export const LOGO_LINES: readonly string[] = [
" 𐓏 ",
" ▀█▀ █▀█ █▀█ █ █ █▄ █ █▄▀",
" █ █▄█ █▀▄ █▄▄ █ █ ▀█ █ █",
];
export const LOGO_WIDTH = Math.max(...LOGO_LINES.map((l) => [...l].length));
export const SPROUT_CELLS: ReadonlySet<string> = new Set(["0,6"]);
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import { wrapStep, windowStart } from "./move";
describe("wrapStep", () => {
it("wraps around both ends", () => {
expect(wrapStep(0, -1, 5)).toBe(4);
expect(wrapStep(4, 1, 5)).toBe(0);
expect(wrapStep(2, 1, 5)).toBe(3);
expect(wrapStep(0, 1, 0)).toBe(0);
});
});
describe("windowStart", () => {
it("keeps the cursor centered within bounds", () => {
expect(windowStart(0, 10, 5)).toBe(0);
expect(windowStart(9, 10, 5)).toBe(5);
expect(windowStart(5, 10, 5)).toBe(3);
expect(windowStart(2, 4, 10)).toBe(0);
});
});
+10
View File
@@ -0,0 +1,10 @@
export function wrapStep(current: number, delta: number, length: number): number {
if (length <= 0) return 0;
return (((current + delta) % length) + length) % length;
}
export function windowStart(cursor: number, total: number, height: number): number {
if (total <= height) return 0;
const half = Math.floor(height / 2);
return Math.max(0, Math.min(cursor - half, total - height));
}
+116
View File
@@ -0,0 +1,116 @@
import { createContext, useContext, useEffect, useState } from "react";
import type { Config } from "../config/config";
import type { DownloadQueue } from "../download/queue";
import type { HistoryItem } from "../download/history";
import type { QueueItem } from "../download/types";
import type { SourceGroup, SourceId } from "../sources/types";
export type View = "splash" | "browser";
export type Category = "all" | "games" | "movies" | "tv" | "anime";
export type Section = Category | "downloads";
export const CATEGORIES: { key: Category; label: string; group?: SourceGroup }[] = [
{ key: "all", label: "All" },
{ key: "games", label: "Games", group: "Games" },
{ key: "movies", label: "Movies", group: "Movies" },
{ key: "tv", label: "TV", group: "TV" },
{ key: "anime", label: "Anime", group: "Anime" },
];
export type Region = "sidebar" | "content" | "help";
export type CaptureMode = "none" | "text" | "esc";
export type DownloadFocus = "downloading" | "paused" | "failed" | "recent";
export interface Store {
config: Config;
setConfig: (c: Config) => void;
queue: DownloadQueue;
view: View;
setView: (v: View) => void;
query: string;
submitQuery: (q: string) => void;
section: Section;
setSection: (s: Section) => void;
region: Region;
setRegion: (r: Region) => void;
captureMode: CaptureMode;
setCaptureMode: (m: CaptureMode) => void;
downloadFocus: DownloadFocus | null;
setDownloadFocus: (f: DownloadFocus | null) => void;
startDownload: (input: {
id: string;
name: string;
magnet: string;
source?: SourceId;
sizeBytes?: number;
}) => void;
notice: string | null;
setNotice: (s: string | null) => void;
quitAll: () => void;
listRows: number;
compact: boolean;
contentWidth: number;
cols: number;
rows: number;
}
export const StoreContext = createContext<Store | null>(null);
export function useStore(): Store {
const s = useContext(StoreContext);
if (!s) throw new Error("Store not available");
return s;
}
export function useQueueItems(queue: DownloadQueue): QueueItem[] {
const [items, setItems] = useState<QueueItem[]>(() => queue.getItems());
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | null = null;
const onUpdate = (): void => {
if (timer) return;
timer = setTimeout(() => {
timer = null;
setItems(queue.getItems());
}, 200);
};
queue.on("update", onUpdate);
onUpdate();
return () => {
queue.off("update", onUpdate);
if (timer) clearTimeout(timer);
};
}, [queue]);
return items;
}
export function useQueueHistory(queue: DownloadQueue): HistoryItem[] {
const [items, setItems] = useState<HistoryItem[]>(() => queue.getHistory());
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | null = null;
const onUpdate = (): void => {
if (timer) return;
timer = setTimeout(() => {
timer = null;
setItems(queue.getHistory());
}, 200);
};
queue.on("update", onUpdate);
onUpdate();
return () => {
queue.off("update", onUpdate);
if (timer) clearTimeout(timer);
};
}, [queue]);
return items;
}
+58
View File
@@ -0,0 +1,58 @@
import type { SourceId } from "../sources/types";
export const COLOR = {
accent: "#a78bfa",
text: "#e9e4f5",
alt: "#b9a7e6",
good: "#86d6a2",
warn: "#f0c560",
bad: "#ee7d92",
bright: "#d8b4fe",
} as const;
export const ICON = {
done: "✓",
error: "✗",
pending: "·",
pointer: "",
dot: "·",
warn: "⚠",
bar: "▌",
down: "↓",
peer: "•",
pause: "⏸",
} as const;
export const RULE = "#6b6577";
export const GUTTER = 2;
export const SOURCE_STYLE: Record<SourceId, { tag: string; color: string }> = {
fitgirl: { tag: "FG", color: COLOR.accent },
yts: { tag: "YTS", color: COLOR.good },
eztv: { tag: "EZTV", color: COLOR.warn },
nyaa: { tag: "NYAA", color: COLOR.bright },
subsplease: { tag: "SUB", color: "#b9a7e6" },
solid: { tag: "SLD", color: "#60a5fa" },
"tpb-movies": { tag: "TPB", color: "#5fd0c5" },
"tpb-tv": { tag: "TPB", color: "#5fd0c5" },
"x1337-movies": { tag: "1337", color: "#f6a55c" },
"x1337-tv": { tag: "1337", color: "#f6a55c" },
};
function rgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function lerpHex(a: string, b: string, t: number): string {
const [ar, ag, ab] = rgb(a);
const [br, bg, bb] = rgb(b);
const c = (x: number, y: number) =>
Math.round(x + (y - x) * t)
.toString(16)
.padStart(2, "0");
return `#${c(ar, br)}${c(ag, bg)}${c(ab, bb)}`;
}
export const ACCENT_RAMP: readonly [string, string] = [COLOR.accent, COLOR.bright];
+72
View File
@@ -0,0 +1,72 @@
import { Box, Text, useInput, useStdin } from "ink";
import { Logo } from "../components/Logo";
import { SearchBar } from "../components/SearchBar";
import { LOGO_WIDTH } from "../logo";
import { useStore } from "../store";
import { sourcesByGroup } from "../../sources/registry";
import { COLOR, ICON } from "../theme";
const CATEGORIES = sourcesByGroup()
.map((g) => g.group.toLowerCase())
.join(` ${ICON.dot} `);
export function Splash() {
const { submitQuery, quitAll, cols, rows } = useStore();
const { isRawModeSupported } = useStdin();
useInput(
(input, key) => {
if (key.escape || (key.ctrl && input === "c")) quitAll();
},
{ isActive: isRawModeSupported },
);
const showLogo = cols >= LOGO_WIDTH + 2;
const barWidth = Math.max(24, Math.min(cols - 6, 62));
return (
<Box
height={Math.max(1, rows - 1)}
flexDirection="column"
justifyContent="center"
alignItems="center"
>
{showLogo ? (
<Logo />
) : (
<Text bold color={COLOR.accent}>
torlink
</Text>
)}
<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={barWidth}>
<SearchBar
width={barWidth}
value=""
editing
placeholder="Search, or paste a magnet…"
onSubmit={submitQuery}
/>
</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>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { promises as fs } from "node:fs";
import path from "node:path";
export function serializeWrites(): (task: () => Promise<void>) => Promise<void> {
let chain: Promise<void> = Promise.resolve();
return (task) => {
chain = chain.then(task).catch(() => {});
return chain;
};
}
export async function writeJsonAtomic(file: string, data: unknown): Promise<void> {
await fs.mkdir(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp`;
await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf8");
await fs.rename(tmp, file);
}
+48
View File
@@ -0,0 +1,48 @@
import { spawn } from "node:child_process";
function run(cmd: string, args: string[]): Promise<string> {
return new Promise((resolve) => {
let out = "";
try {
const proc = spawn(cmd, args, { windowsHide: true });
const timer = setTimeout(() => {
try {
proc.kill();
} catch {}
resolve("");
}, 4000);
timer.unref?.();
proc.stdout.on("data", (d: Buffer) => (out += d.toString("utf8")));
proc.on("error", () => {
clearTimeout(timer);
resolve("");
});
proc.on("close", () => {
clearTimeout(timer);
resolve(out);
});
} catch {
resolve("");
}
});
}
const LINUX: [string, string[]][] = [
["wl-paste", ["--no-newline"]],
["xclip", ["-selection", "clipboard", "-o"]],
["xsel", ["-b"]],
];
export async function readClipboard(): Promise<string> {
if (process.platform === "win32") {
return (await run("powershell", ["-NoProfile", "-Command", "Get-Clipboard"])).trim();
}
if (process.platform === "darwin") {
return (await run("pbpaste", [])).trim();
}
for (const [cmd, args] of LINUX) {
const out = (await run(cmd, args)).trim();
if (out) return out;
}
return "";
}
+69
View File
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import {
formatBytes,
parseSize,
formatBytesPerSec,
formatRelative,
formatEtaShort,
cleanText,
truncate,
} from "./format";
describe("formatBytes", () => {
it("formats across units", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(933)).toBe("933 B");
expect(formatBytes(1024)).toBe("1.00 KB");
expect(formatBytes(2.1e9)).toBe("1.96 GB");
expect(formatBytes(undefined)).toBe("0 B");
});
});
describe("parseSize", () => {
it("parses human sizes to bytes", () => {
expect(parseSize("1.4 GiB")).toBe(Math.round(1.4 * 1024 ** 3));
expect(parseSize("700 MB")).toBe(700_000_000);
expect(parseSize("350.2 MiB")).toBe(Math.round(350.2 * 1024 ** 2));
expect(parseSize("nothing here")).toBe(0);
});
});
describe("formatBytesPerSec", () => {
it("formats rates and blanks zero", () => {
expect(formatBytesPerSec(0)).toBe("");
expect(formatBytesPerSec(5.2e6)).toMatch(/MB\/s$/);
});
});
describe("formatRelative", () => {
it("describes recent times", () => {
const now = Date.now() / 1000;
expect(formatRelative(now - 30)).toBe("now");
expect(formatRelative(now - 125)).toBe("2m ago");
expect(formatRelative(0)).toBe("");
});
});
describe("formatEtaShort", () => {
it("formats remaining time compactly", () => {
expect(formatEtaShort(45)).toBe("45s");
expect(formatEtaShort(125)).toBe("2m 5s");
expect(formatEtaShort(3725)).toBe("1hr 2m");
expect(formatEtaShort(90061)).toBe("1d 1hr 1m");
expect(formatEtaShort(undefined)).toBe("");
});
});
describe("cleanText", () => {
it("strips junk glyphs and collapses whitespace", () => {
expect(cleanText("Foo 🎬 Bar")).toBe("Foo Bar");
expect(cleanText("🎬🎬")).toBe("Untitled");
});
});
describe("truncate", () => {
it("truncates with an ellipsis", () => {
expect(truncate("hello world", 5)).toBe("hell…");
expect(truncate("hi", 5)).toBe("hi");
});
});
+104
View File
@@ -0,0 +1,104 @@
export function formatBytes(bytes?: number): string {
if (bytes === undefined || !Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let n = bytes;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
}
const SIZE_UNITS: Record<string, number> = {
B: 1,
KIB: 1024,
MIB: 1024 ** 2,
GIB: 1024 ** 3,
TIB: 1024 ** 4,
KB: 1000,
MB: 1e6,
GB: 1e9,
TB: 1e12,
};
export function parseSize(s: string): number {
const m = s.match(/([\d.]+)\s*([KMGT]?I?B)/i);
if (!m) return 0;
return Math.round(parseFloat(m[1]!) * (SIZE_UNITS[m[2]!.toUpperCase()] ?? 1));
}
export function formatBytesPerSec(bytes?: number): string {
if (bytes === undefined || !Number.isFinite(bytes) || bytes <= 0) return "";
const units = ["B/s", "KB/s", "MB/s", "GB/s"];
let n = bytes;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
export function formatRelative(unixSeconds?: number): string {
if (!unixSeconds || !Number.isFinite(unixSeconds) || unixSeconds <= 0) return "";
const diff = Date.now() / 1000 - unixSeconds;
if (diff < 60) return "now";
const m = Math.floor(diff / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) {
const rm = m % 60;
return rm > 0 ? `${h}hr ${rm}m ago` : `${h}hr ago`;
}
const d = Math.floor(h / 24);
if (d < 30) {
const rh = h % 24;
return rh > 0 ? `${d}d ${rh}hr ago` : `${d}d ago`;
}
const mo = Math.floor(d / 30);
if (mo < 12) return `${mo}mo ago`;
return `${Math.floor(mo / 12)}y ago`;
}
export function formatEtaShort(sec?: number): string {
if (sec === undefined || !Number.isFinite(sec) || sec < 0) return "";
const total = Math.round(sec);
const d = Math.floor(total / 86400);
const h = Math.floor((total % 86400) / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (d > 0)
return [`${d}d`, h > 0 ? `${h}hr` : "", m > 0 ? `${m}m` : ""]
.filter(Boolean)
.join(" ");
if (h > 0) return m > 0 ? `${h}hr ${m}m` : `${h}hr`;
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`;
return `${s}s`;
}
function isJunkCodePoint(cp: number): boolean {
if (cp < 0x20 || cp === 0x7f) return true;
if (cp === 0xfffd) return true;
if (cp >= 0x200b && cp <= 0x200f) return true;
if (cp >= 0x2028 && cp <= 0x202e) return true;
if (cp === 0x2060 || cp === 0xfeff) return true;
if (cp === 0x200d || cp === 0xfe0f || cp === 0x20e3) return true;
if (cp >= 0x2600 && cp <= 0x27bf) return true;
if (cp >= 0x2b00 && cp <= 0x2bff) return true;
if (cp >= 0x1f000 && cp <= 0x1ffff) return true;
return false;
}
export function cleanText(s: string): string {
let out = "";
for (const ch of s.normalize("NFC")) {
if (!isJunkCodePoint(ch.codePointAt(0)!)) out += ch;
}
return out.replace(/\s+/g, " ").trim() || "Untitled";
}
export function truncate(s: string, max: number): string {
if (max <= 1) return s.slice(0, Math.max(0, max));
return s.length <= max ? s : s.slice(0, max - 1) + "…";
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { parseRetryAfter, backoffDelay, fetchResilient, HttpError } from "./net";
function fakeRes(status: number, headers: Record<string, string> = {}): Response {
return {
status,
ok: status >= 200 && status < 300,
headers: { get: (k: string) => headers[k.toLowerCase()] ?? null },
} as unknown as Response;
}
describe("parseRetryAfter", () => {
it("parses delta-seconds and rejects garbage", () => {
expect(parseRetryAfter("5")).toBe(5000);
expect(parseRetryAfter(null)).toBeUndefined();
expect(parseRetryAfter("soon")).toBeUndefined();
});
it("parses an HTTP date relative to now", () => {
const future = new Date(Date.now() + 10_000).toUTCString();
const ms = parseRetryAfter(future);
expect(ms).toBeGreaterThan(5_000);
expect(ms).toBeLessThanOrEqual(10_000);
});
});
describe("backoffDelay", () => {
it("applies jitter and honors retry-after as a floor", () => {
expect(backoffDelay(0, 500, 20000, undefined, () => 0.5)).toBe(250);
expect(backoffDelay(2, 500, 20000, undefined, () => 0)).toBe(0);
expect(backoffDelay(0, 500, 20000, 1000, () => 0.5)).toBe(1000);
});
});
describe("fetchResilient", () => {
const opts = { sleepImpl: async () => {}, baseMs: 1, capMs: 1 };
it("retries a 503 then returns the success response", async () => {
let calls = 0;
const res = await fetchResilient("http://x", {
...opts,
retries: 3,
fetchImpl: async () => (++calls === 1 ? fakeRes(503) : fakeRes(200)),
});
expect(res.status).toBe(200);
expect(calls).toBe(2);
});
it("throws after exhausting retries", async () => {
await expect(
fetchResilient("http://x", { ...opts, retries: 2, fetchImpl: async () => fakeRes(503) }),
).rejects.toBeInstanceOf(HttpError);
});
it("throws immediately when the signal is already aborted", async () => {
const ctrl = new AbortController();
ctrl.abort();
await expect(
fetchResilient("http://x", { ...opts, signal: ctrl.signal, fetchImpl: async () => fakeRes(200) }),
).rejects.toBeInstanceOf(HttpError);
});
it("does not retry a non-retryable status", async () => {
let calls = 0;
const res = await fetchResilient("http://x", {
...opts,
fetchImpl: async () => (++calls, fakeRes(404)),
});
expect(res.status).toBe(404);
expect(calls).toBe(1);
});
});
+122
View File
@@ -0,0 +1,122 @@
export const USER_AGENT = "torlink (+https://www.npmjs.com/package/torlink)";
export type FetchImpl = (url: string, init?: RequestInit) => Promise<Response>;
export type SleepImpl = (ms: number) => Promise<void>;
export interface FetchResilientOptions extends RequestInit {
retries?: number;
baseMs?: number;
capMs?: number;
fetchImpl?: FetchImpl;
sleepImpl?: SleepImpl;
}
export class HttpError extends Error {
status: number;
constructor(status: number, message?: string) {
super(message ?? `HTTP ${status}`);
this.name = "HttpError";
this.status = status;
}
}
export const RETRY_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
const DEFAULT_RETRIES = 5;
const DEFAULT_BASE_MS = 500;
const DEFAULT_CAP_MS = 20000;
function realSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isAbortError(e: unknown): boolean {
return (
e instanceof Error &&
(e.name === "AbortError" || /aborted/i.test(e.message))
);
}
export function parseRetryAfter(
value: string | null,
nowMs = Date.now(),
): number | undefined {
if (!value) return undefined;
const trimmed = value.trim();
if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1000;
const date = Date.parse(trimmed);
if (!Number.isNaN(date)) return Math.max(0, date - nowMs);
return undefined;
}
export function backoffDelay(
attempt: number,
baseMs: number,
capMs: number,
retryAfterMs?: number,
rand: () => number = Math.random,
): number {
const exp = Math.min(capMs, baseMs * 2 ** attempt);
const jittered = Math.floor(rand() * exp);
if (retryAfterMs !== undefined) return Math.max(jittered, retryAfterMs);
return jittered;
}
export async function fetchResilient(
url: string,
opts: FetchResilientOptions = {},
): Promise<Response> {
const {
retries = DEFAULT_RETRIES,
baseMs = DEFAULT_BASE_MS,
capMs = DEFAULT_CAP_MS,
fetchImpl = fetch as FetchImpl,
sleepImpl = realSleep,
signal,
...init
} = opts;
const fetchInit: RequestInit = signal ? { ...init, signal } : init;
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
if (signal?.aborted) throw new HttpError(0, "aborted");
let res: Response | undefined;
try {
res = await fetchImpl(url, fetchInit);
} catch (e) {
if (isAbortError(e) || signal?.aborted) throw e;
lastError = e;
if (attempt < retries) {
await sleepImpl(backoffDelay(attempt, baseMs, capMs));
continue;
}
throw e;
}
if (!RETRY_STATUS.has(res.status)) return res;
const server = res.headers.get("server")?.toLowerCase() || "";
if (res.status === 503 && (server.includes("ddos-guard") || server.includes("cloudflare"))) {
throw new HttpError(
res.status,
`Request to ${url} blocked by ${server} (HTTP ${res.status}).`,
);
}
if (attempt >= retries) {
throw new HttpError(
res.status,
`Request to ${url} failed after ${retries} retries (HTTP ${res.status}).`,
);
}
const retryAfterMs = parseRetryAfter(res.headers.get("retry-after"));
await sleepImpl(backoffDelay(attempt, baseMs, capMs, retryAfterMs));
}
throw lastError instanceof Error
? lastError
: new HttpError(0, "fetchResilient exhausted without a response");
}
+1
View File
@@ -0,0 +1 @@
export const VERSION = "1.0.0";
+59
View File
@@ -0,0 +1,59 @@
declare module "webtorrent" {
import type { EventEmitter } from "node:events";
interface TorrentFile {
name: string;
path: string;
length: number;
}
interface Torrent extends EventEmitter {
infoHash: string;
magnetURI: string;
name: string;
length: number;
downloaded: number;
uploaded: number;
downloadSpeed: number;
uploadSpeed: number;
progress: number;
numPeers: number;
timeRemaining: number;
done: boolean;
paused: boolean;
path: string;
files: TorrentFile[];
pause(): void;
resume(): void;
destroy(cb?: (err?: Error) => void): void;
}
interface TorrentOptions {
path?: string;
announce?: string[];
}
interface WebTorrentOptions {
maxConns?: number;
dht?: boolean;
utp?: boolean;
}
class WebTorrent extends EventEmitter {
constructor(opts?: WebTorrentOptions);
readonly torrents: Torrent[];
readonly downloadSpeed: number;
readonly uploadSpeed: number;
add(
torrentId: string,
opts?: TorrentOptions,
cb?: (torrent: Torrent) => void,
): Torrent;
get(torrentId: string): Torrent | null;
remove(torrentId: string, cb?: (err?: Error) => void): void;
destroy(cb?: (err?: Error) => void): void;
}
export default WebTorrent;
export type { Torrent, TorrentFile };
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"jsxImportSource": "react",
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": ["src", "scripts"],
"exclude": ["node_modules", "dist"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.tsx"],
format: ["esm"],
target: "node22",
platform: "node",
banner: { js: "#!/usr/bin/env node" },
clean: true,
sourcemap: false,
dts: false,
splitting: false,
shims: false,
minify: true,
esbuildOptions(options) {
options.jsx = "automatic";
options.jsxImportSource = "react";
},
});