diff --git a/.gitignore b/.gitignore index 526224d..e5ece35 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ .DS_Store package-lock.json !nix/package-lock.json +/diagnostics/ diff --git a/README.md b/README.md index bae78e8..46c918d 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,39 @@ # torhunt -A sleek, zero-setup torrent finder and downloader that lives right in your terminal. +A fast, distraction-free torrent search engine and downloader built for your terminal. -Finding a torrent these days sucks. One site is a minefield of fake download buttons. Another hides the real link under a popup that spawns two more tabs. And after all that, half the results are dead, zero seeders. +Modern torrent searching is broken. Most indexers are cluttered with intrusive ads, misleading download buttons, and dead magnet links. -torhunt fixes that. One search checks a short, curated list of reputable sources at once, and whatever you pick downloads straight to your computer. No browser, no ads, no nonsense. The files are yours, saved to your downloads folder. +torhunt cuts through the noise. A single search queries a curated selection of reliable sources simultaneously, streaming results straight to your terminal and downloading directly to your system. No browser tabs, no popups, no hassle. -## Get started +## Quick start -1. **Install Node** (from [nodejs.org](https://nodejs.org)), it's all torhunt needs. +1. **Install Node.js** (v22+ from [nodejs.org](https://nodejs.org)). 2. **Open your terminal.** -3. **Start it:** +3. **Launch:** ```sh npx torhunt ``` -That's it. torhunt opens straight to a search bar: search for what you want, paste in a magnet link or a bare infohash, or just press Enter on an empty box to browse the curated library. From there it's all keypresses — nothing to memorize, and `?` brings up the full list anytime. +That's all it takes. torhunt opens directly to an interactive search prompt: type your query, paste a magnet link or infohash, or press Enter on an empty query to browse curated picks. Everything is controlled via simple hotkeys — press `?` anytime for the full keymap. ## Features -- **Instant search** — type and hit Enter. Results stream in from every source, tagged with size and peer count so you can see what'll come down fast. -- **One-key downloads** — arrow to what you want and press `d` to save it, or `D` to pick a different folder for just that download. -- **Background downloads** — keep searching while downloads run. Queue up as many as you want. -- **Resume on restart** — anything interrupted picks up where it left off. -- **Seeding controls** — finished downloads seed automatically. Pause or stop anytime from the Seeding tab. -- **Completed library** — every finished download is archived in the Completed tab, grouped by date (Today, Yesterday, Older). -- **Settings panel** — change your download folder, color theme, and spinner style from within the app. -- **Global search** — press `/` from anywhere to jump straight to search. -- **Customizable themes** — multiple built-in color themes to match your terminal aesthetic. -- **Quality filters** — filter results by quality (4K, 1080p, 720p, x265, FLAC, FitGirl) with a single keypress. +- **Concurrent search** — Query multiple indexers in parallel with real-time streaming results tagged by file size and live peer counts. +- **One-key downloads** — Navigate to any item and press `d` to start downloading, or `D` to choose a custom target directory. +- **Non-blocking queue** — Continue searching and browsing while transfers run in the background. +- **State auto-resume** — Interrupted downloads pick up seamlessly where they left off after a restart. +- **Seeding manager** — Finished downloads seed automatically. Pause, resume, or stop seeding anytime from the Seeding view. +- **Completed archive** — Organized record of finished downloads grouped by date (Today, Yesterday, Older). +- **In-app settings** — Change your download directory, color theme, and spinner animation on the fly. +- **Instant navigation** — Press `/` from any view to jump straight to the search field. +- **Custom visual themes** — Hand-crafted color palettes to match your terminal environment. +- **Quality filters** — Filter results by resolution and format (4K, 1080p, 720p, x265, FLAC, FitGirl) with a single keypress. -## What it searches +## Indexer sources -A short, hand-picked list of trusted sources: +torhunt queries a curated list of trusted sources by category: | Category | Sources | | --- | --- | @@ -42,24 +42,24 @@ A short, hand-picked list of trusted sources: | TV | EZTV, The Pirate Bay, 1337x, BitTorrented | | 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. If a source is down, the search carries on without it, and torhunt tells you which one is offline. +Game downloads are restricted to FitGirl due to their verified repack safety track record. Movies, TV, and anime sources cover clean video and audio streams. If any source is offline, search continues smoothly while displaying a status indicator. -## Headless mode +## Headless & server modes -torhunt also runs without the TUI, for servers and seedboxes: +torhunt includes headless background daemons for servers and seedboxes: -``` -torhunt watch download anything dropped into a folder -torhunt serve take magnets over HTTP -torhunt files stream finished downloads over HTTP -torhunt attach keep the TUI alive across ssh sessions +```sh +torhunt watch download torrents or magnets dropped into a folder +torhunt serve HTTP API for remote magnet submission +torhunt files range-aware HTTP server for media streaming +torhunt attach persistent tmux session for remote SSH usage ``` -Add `--daemon` to keep watch, serve, or files running after you log out. Run `torhunt --help` for the full list of modes and flags. +Append `--daemon` to run `watch`, `serve`, or `files` as background processes. Run `torhunt --help` for all commands and flags. -## Privacy +## Privacy & security -Your files stay on your disk, and nothing routes through a central server — torhunt only talks to the torrent network directly. Once a download finishes it keeps seeding by default, sharing it back so the next person can find it too. Opt out anytime from the Seeding tab. +torhunt connects directly to the BitTorrent P2P swarm. No telemetry, tracking, or proxying through third-party servers. All files land on your local storage, and seeding behavior can be toggled anytime. ## Development @@ -70,7 +70,7 @@ npm install npm run dev ``` -`npm run dev` runs the live TUI through tsx, no build step needed. To build and run the bundled version: +`npm run dev` launches the live TUI via `tsx`. To build and execute the production bundle: ```sh npm run build diff --git a/flake.nix b/flake.nix index 3ae2f4e..64409b4 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Torlink is a torrent finder that lives in your terminal, with zero setup and nothing to configure."; + description = "Torhunt is a fast, distraction-free torrent search engine and downloader built for your terminal."; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; @@ -26,7 +26,7 @@ } ); overlays.default = final: prev: { - torlink = final.callPackage ./nix/package.nix { }; + torhunt = final.callPackage ./nix/package.nix { }; }; }; diff --git a/nix/README.md b/nix/README.md index 3a59d70..8339ab3 100644 --- a/nix/README.md +++ b/nix/README.md @@ -1,12 +1,12 @@ # Flake install Add this repo to your ```flake.nix```. The package is built using the unstable channel. You can overwrite this by setting ```inputs.nixpkgs.follows = "nixpkgs"``` (if your default is 26.05). -**The binary is executed as ```torlnk```.** +**The binary is executed as ```torhunt```.** ```nix inputs = { ... - torlink.url = "github:baairon/torlink"; + torhunt.url = "github:pseudoshell/torhunt"; ... } ``` @@ -21,7 +21,7 @@ You can install the package in either home.nix or your configuration.nix dependi { home.packages = with pkgs; [ ... - inputs.torlink.packages.${pkgs.system}.default + inputs.torhunt.packages.${pkgs.system}.default ... ]; } @@ -35,7 +35,7 @@ You can install the package in either home.nix or your configuration.nix dependi { environment.systemPackages = with pkgs; [ ... - inputs.torlink.packages.${pkgs.system}.default + inputs.torhunt.packages.${pkgs.system}.default ... ]; } diff --git a/nix/package.nix b/nix/package.nix index 16eda33..19eb3a1 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -47,11 +47,11 @@ let in buildNpmPackage (finalAttrs: { - pname = "torlink"; - version = "1.4.1"; + pname = "torhunt"; + version = "1.12.0"; src = fetchFromGitHub { - owner = "baairon"; - repo = "torlink"; + owner = "pseudoshell"; + repo = "torhunt"; tag = "v${finalAttrs.version}"; hash = "sha256-VXfYzwjhSS+zZCnGoRUCVGgmuRaV5KeYASASM4E9Xj4="; }; @@ -72,7 +72,7 @@ buildNpmPackage (finalAttrs: { # build node-datachannel, and wrap clipboard postInstall = '' - pushd $out/lib/node_modules/torlnk/node_modules/node-datachannel + pushd $out/lib/node_modules/torhunt/node_modules/node-datachannel # link shared nixpkgs openssl substituteInPlace CMakeLists.txt \ @@ -98,7 +98,7 @@ buildNpmPackage (finalAttrs: { popd # wrap clipboard - wrapProgram $out/bin/torlnk \ + wrapProgram $out/bin/torhunt \ --prefix PATH : ${ lib.makeBinPath [ wl-clipboard @@ -108,12 +108,12 @@ buildNpmPackage (finalAttrs: { ''; meta = { - description = "Torlink is a torrent finder that lives in your terminal, with zero setup and nothing to configure."; - homepage = "https://github.com/baairon/torlink"; - changelog = "https://github.com/baairon/torlink/releases/tag/${finalAttrs.src.tag}"; + description = "Torhunt is a fast, distraction-free torrent search engine and downloader built for your terminal."; + homepage = "https://github.com/pseudoshell/torhunt"; + changelog = "https://github.com/pseudoshell/torhunt/releases/tag/${finalAttrs.src.tag}"; license = lib.licenses.mit; maintainers = with lib.maintainers; [ ghastrum ]; - mainProgram = "torlnk"; + mainProgram = "torhunt"; platforms = lib.platforms.linux; }; }) diff --git a/package-lock.json b/package-lock.json index d3f5c8c..2bac6d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "torlnk", - "version": "1.9.1", + "version": "2.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "torlnk", - "version": "1.9.1", + "version": "2.0.3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 903c8c7..027173a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "torhunt", - "version": "1.9.1", - "description": "A sleek, zero-setup torrent finder and downloader that lives right in your terminal.", + "version": "2.0.3", + "description": "A fast, distraction-free torrent search engine and downloader built for your terminal.", "type": "module", "bin": { "torhunt": "dist/cli.cjs" diff --git a/scripts/cli-entry.cjs b/scripts/cli-entry.cjs index 51b9ac2..2c64a32 100644 --- a/scripts/cli-entry.cjs +++ b/scripts/cli-entry.cjs @@ -4,7 +4,7 @@ var major = parseInt(process.versions.node.split('.')[0], 10); if (major < 22) { process.stderr.write( - '\ntorlnk requires Node.js v22 or later.\n' + + '\ntorhunt requires Node.js v22 or later.\n' + 'You are running v' + process.versions.node + '.\n\n' + 'Upgrade: https://nodejs.org\n' + 'With nvm: nvm install 22 && nvm use 22\n\n' @@ -35,22 +35,22 @@ try { }, }); process.stderr.write( - 'torlnk: WebRTC peers unavailable (native module not installed); ' + - 'TCP/UDP peers still work. https://github.com/baairon/torlink/issues/60\n' + 'torhunt: WebRTC peers unavailable (native module not installed); ' + + 'TCP/UDP peers still work. https://github.com/pseudoshell/torhunt/issues/60\n' ); } else { // Node 22.0 to 22.14 has no module.registerHooks, so the eager import // cannot be redirected; a clear explanation beats the raw module error. process.stderr.write( - '\ntorlnk needs the WebRTC native module (node-datachannel), and it is\n' + - 'not installed. Either upgrade to Node 22.15+ (torlnk then runs\n' + + '\ntorhunt needs the WebRTC native module (node-datachannel), and it is\n' + + 'not installed. Either upgrade to Node 22.15+ (torhunt then runs\n' + 'without WebRTC peers), or install the build tools and reinstall:\n' + ' Fedora: sudo dnf install cmake gcc-c++ openssl-devel libstdc++-static\n' + ' Debian / Ubuntu: sudo apt install cmake g++ libssl-dev\n' + ' macOS: xcode-select --install\n' + ' Windows: install CMake and Visual Studio Build Tools\n' + 'On npm 12, also allow install scripts: npm approve-scripts\n\n' + - 'https://github.com/baairon/torlink/issues/60\n\n' + 'https://github.com/pseudoshell/torhunt/issues/60\n\n' ); process.exit(1); } diff --git a/scripts/ensure-webrtc.cjs b/scripts/ensure-webrtc.cjs index c74b734..8359866 100644 --- a/scripts/ensure-webrtc.cjs +++ b/scripts/ensure-webrtc.cjs @@ -4,7 +4,7 @@ const { execSync } = require('node:child_process'); const { existsSync } = require('node:fs'); const { resolve } = require('node:path'); -// During postinstall the CWD is always torlink's root directory. +// During postinstall the CWD is always torhunt's root directory. // Check whether the native module actually loads; if prebuild-install // succeeded on its own (Node 18/20) there is nothing to do. require() // resolves through any node_modules layout, so this check is layout-proof. @@ -28,7 +28,7 @@ if (!moduleDir) { process.exit(0); // nothing installed, nothing to build } -console.error('\ntorlnk: building WebRTC native module from source.\n'); +console.error('\ntorhunt: building WebRTC native module from source.\n'); try { execSync('npx --yes cmake-js build', { @@ -38,18 +38,18 @@ try { timeout: 300000, }); } catch { - // Warn but never fail the install: torlink works without WebRTC peers + // Warn but never fail the install: torhunt works without WebRTC peers // (TCP/uTP swarms still connect), so a missing toolchain must not brick // `npm install`. console.error(''); - console.error('torlnk: could not build the WebRTC native module.'); - console.error('torlnk still works; WebRTC peers just stay unavailable.'); + console.error('torhunt: could not build the WebRTC native module.'); + console.error('torhunt still works; WebRTC peers just stay unavailable.'); console.error('To enable them, install the build tools, then reinstall:'); console.error(' Fedora: sudo dnf install cmake gcc-c++ openssl-devel libstdc++-static'); console.error(' Debian / Ubuntu: sudo apt install cmake g++ libssl-dev'); console.error(' macOS: xcode-select --install'); console.error(' Windows: install CMake and Visual Studio Build Tools'); console.error(''); - console.error('https://github.com/baairon/torlink/issues/60'); + console.error('https://github.com/pseudoshell/torhunt/issues/60'); } process.exit(0); diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx index e80e0a4..e84628a 100644 --- a/scripts/render-previews-impl.tsx +++ b/scripts/render-previews-impl.tsx @@ -83,7 +83,8 @@ function makeStore( const noop = (): void => {}; return { config: { - downloadDir: "~/Downloads/torlink", + downloadDir: "~/Downloads/torhunt", + categorySubfolders: true, theme: "electric-cyan", spinner: "dots", trackers: [], @@ -125,6 +126,7 @@ function makeStore( openThemePicker: noop, openSpinnerPicker: noop, openFolderPicker: noop, + openQrModal: noop, searchModeTrigger: 0, triggerSearch: noop, bookmarks: [], diff --git a/scripts/verify-seeding.ts b/scripts/verify-seeding.ts index 570da1b..c6ced55 100644 --- a/scripts/verify-seeding.ts +++ b/scripts/verify-seeding.ts @@ -1,5 +1,5 @@ /** - * Proves torlink's seeding actually transfers bytes (not just a UI label), and + * Proves torhunt's seeding actually transfers bytes (not just a UI label), and * that pause and resume really stop/restart it. * * Offline-deterministic: the leechers run with dht/tracker/lsd OFF and are @@ -86,7 +86,7 @@ async function waitSeederReady( } async function main(): Promise { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-seedcheck-")); + const root = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-seedcheck-")); const seedDir = path.join(root, "seed"); await fs.mkdir(seedDir, { recursive: true }); const filePath = path.join(seedDir, "payload.bin"); @@ -94,7 +94,7 @@ async function main(): Promise { log(`payload: ${filePath} (${FILE_BYTES} bytes)`); // Mint the .torrent metadata for the on-disk file via a throwaway client, then - // drop it. This is what torlink now captures at download time and seeds from. + // drop it. This is what torhunt now captures at download time and seeds from. const minter = new WebTorrent({ dht: false, tracker: false, lsd: false }); const { meta, infoHash } = await new Promise<{ meta: Uint8Array; infoHash: string }>( (resolve) => { diff --git a/src/cli/args.ts b/src/cli/args.ts index 6930bf4..f3e8275 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -156,16 +156,16 @@ after it finishes (e.g. 1h, 30m, 90s, 2d); files are kept by default. Add --daemon (watch/serve/files): background the process (own session, logs to a file), so you can log out and it keeps running. Prints the pid and log path. -torlnk attach: run the TUI inside a persistent tmux session. Detach with -tmux's ctrl-b d, log out, then torlnk attach again to reattach where you +torhunt attach: run the TUI inside a persistent tmux session. Detach with +tmux's ctrl-b d, log out, then torhunt attach again to reattach where you left off. Downloads and seeds keep running while detached. -serve mode (no TUI): a small HTTP API for handing torlink a magnet. +serve mode (no TUI): a small HTTP API for handing torhunt a magnet. POST /add {"magnet":"..."} queue a magnet or info hash GET /downloads list active downloads and seeds GET /health liveness (no auth) flags: --port (default 9161), --host (default 127.0.0.1), ---token (required to bind a public --host; or TORLINK_API_TOKEN), +--token (required to bind a public --host; or TORHUNT_API_TOKEN), --to (where files land). files mode (no TUI): a read-only, range-aware HTTP server over the downloads @@ -173,6 +173,6 @@ folder, so finished files stream to a browser or media player. GET / list the folder (JSON) GET / stream a file (supports Range for seeking/resuming) flags: --port (default 9160), --host (default 127.0.0.1), ---token (required to bind a public --host; or TORLINK_FILES_TOKEN), +--token (required to bind a public --host; or TORHUNT_FILES_TOKEN), --dir (folder to serve; defaults to your downloads folder). `; diff --git a/src/config/config.ts b/src/config/config.ts index a5f5e9f..3d134bd 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -6,6 +6,7 @@ export type OnCompleteAction = "none" | "sleep" | "shutdown"; export interface Config { downloadDir: string; + categorySubfolders: boolean; trackers: string[]; theme: string; spinner: string; @@ -16,6 +17,7 @@ export interface Config { export const defaultConfig: Config = { downloadDir: defaultDownloadDir, + categorySubfolders: true, trackers: [], theme: "electric-cyan", spinner: "meter", @@ -38,6 +40,10 @@ export async function loadConfig(): Promise { typeof parsed.downloadDir === "string" && parsed.downloadDir ? parsed.downloadDir : defaultDownloadDir, + categorySubfolders: + typeof parsed.categorySubfolders === "boolean" + ? parsed.categorySubfolders + : defaultConfig.categorySubfolders, trackers: Array.isArray(parsed.trackers) ? parsed.trackers.filter((t): t is string => typeof t === "string" && t.length > 0) : [], diff --git a/src/config/folder.test.ts b/src/config/folder.test.ts index 3b7023e..46f2ddc 100644 --- a/src/config/folder.test.ts +++ b/src/config/folder.test.ts @@ -1,6 +1,11 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { expandHome, normalizeDownloadDir } from "./folder"; +import { + expandHome, + getCategoryForSource, + normalizeDownloadDir, + resolveDownloadDir, +} from "./folder"; const HOME = path.join(path.sep, "home", "ada"); @@ -33,8 +38,8 @@ describe("normalizeDownloadDir", () => { }); it("normalizes a tilde path into a usable directory", () => { - expect(normalizeDownloadDir("~/Downloads/torlink", HOME)).toBe( - path.normalize(path.join(HOME, "Downloads", "torlink")), + expect(normalizeDownloadDir("~/Downloads/torhunt", HOME)).toBe( + path.normalize(path.join(HOME, "Downloads", "torhunt")), ); }); @@ -43,3 +48,60 @@ describe("normalizeDownloadDir", () => { expect(normalizeDownloadDir("Z:", HOME)).toBe(path.normalize("Z:\\Downloads")); }); }); + +describe("getCategoryForSource", () => { + it("maps fitgirl to Games", () => { + expect(getCategoryForSource("fitgirl")).toBe("Games"); + }); + + it("maps yts and tpb-movies to Movies", () => { + expect(getCategoryForSource("yts")).toBe("Movies"); + expect(getCategoryForSource("tpb-movies")).toBe("Movies"); + }); + + it("maps eztv to TV", () => { + expect(getCategoryForSource("eztv")).toBe("TV"); + }); + + it("maps nyaa and subsplease to Anime", () => { + expect(getCategoryForSource("nyaa")).toBe("Anime"); + expect(getCategoryForSource("subsplease")).toBe("Anime"); + }); + + it("defaults unknown or missing sources to Other", () => { + expect(getCategoryForSource(undefined)).toBe("Other"); + expect(getCategoryForSource("unknown-source")).toBe("Other"); + }); +}); + +describe("resolveDownloadDir", () => { + it("nests torhunt parent folder and category subfolders when enabled", () => { + const base = path.join(HOME, "Downloads"); + expect(resolveDownloadDir(base, { source: "yts", categorySubfolders: true }, HOME)).toBe( + path.normalize(path.join(HOME, "Downloads", "torhunt", "Movies")), + ); + expect(resolveDownloadDir(base, { source: "fitgirl", categorySubfolders: true }, HOME)).toBe( + path.normalize(path.join(HOME, "Downloads", "torhunt", "Games")), + ); + expect(resolveDownloadDir(base, { source: undefined, categorySubfolders: true }, HOME)).toBe( + path.normalize(path.join(HOME, "Downloads", "torhunt", "Other")), + ); + }); + + it("uses torhunt parent folder without category subfolder when categorySubfolders is false", () => { + const base = path.join(HOME, "Downloads"); + expect(resolveDownloadDir(base, { source: "yts", categorySubfolders: false }, HOME)).toBe( + path.normalize(path.join(HOME, "Downloads", "torhunt")), + ); + }); + + it("prevents double torhunt/torhunt nesting if input path already ends in torhunt", () => { + const base = path.join(HOME, "Downloads", "torhunt"); + expect(resolveDownloadDir(base, { source: "yts", categorySubfolders: true }, HOME)).toBe( + path.normalize(path.join(HOME, "Downloads", "torhunt", "Movies")), + ); + expect(resolveDownloadDir(base, { source: "yts", categorySubfolders: false }, HOME)).toBe( + path.normalize(path.join(HOME, "Downloads", "torhunt")), + ); + }); +}); diff --git a/src/config/folder.ts b/src/config/folder.ts index f61da1e..1250885 100644 --- a/src/config/folder.ts +++ b/src/config/folder.ts @@ -23,3 +23,38 @@ export function normalizeDownloadDir(input: string, home: string = os.homedir()) } return path.normalize(expanded); } + +export function getCategoryForSource(source?: string): string { + if (!source) return "Other"; + const lower = source.toLowerCase(); + if (lower.includes("fitgirl")) return "Games"; + if (lower.includes("yts") || lower.includes("movies")) return "Movies"; + if (lower.includes("eztv") || lower.includes("tv")) return "TV"; + if (lower.includes("nyaa") || lower.includes("subsplease") || lower.includes("anime")) return "Anime"; + return "Other"; +} + +export interface FolderResolveOptions { + source?: string; + categorySubfolders?: boolean; +} + +export function resolveDownloadDir( + inputDir: string, + options?: FolderResolveOptions, + home: string = os.homedir(), +): string { + const norm = normalizeDownloadDir(inputDir, home); + if (!norm) return ""; + + const baseName = path.basename(norm).toLowerCase(); + const torhuntDir = baseName === "torhunt" ? norm : path.join(norm, "torhunt"); + + const enabled = options?.categorySubfolders ?? true; + if (!enabled) { + return path.normalize(torhuntDir); + } + + const category = getCategoryForSource(options?.source); + return path.normalize(path.join(torhuntDir, category)); +} diff --git a/src/config/paths.ts b/src/config/paths.ts index 410008e..5e93df1 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -9,7 +9,7 @@ const base = envPaths(APP_NAME, { suffix: "" }); // Optional override that relocates all persisted state under one folder. Tests // point this at a temp dir so they never touch the real user data; it also // doubles as a portable-state escape hatch. Off unless the env var is set. -const override = process.env.TORLINK_STATE_DIR; +const override = process.env.TORHUNT_STATE_DIR; const dataDir = override ? path.join(override, "data") : base.data; const configDir = override ? path.join(override, "config") : base.config; diff --git a/src/daemon/attach.test.ts b/src/daemon/attach.test.ts index 2387a25..7224314 100644 --- a/src/daemon/attach.test.ts +++ b/src/daemon/attach.test.ts @@ -3,8 +3,8 @@ import { tuiCommand, SESSION } from "./attach"; describe("tuiCommand", () => { it("sh-quotes the node binary and script so tmux runs the plain TUI", () => { - expect(tuiCommand("/usr/bin/node", "/opt/torlink/dist/index.js")).toBe( - "'/usr/bin/node' '/opt/torlink/dist/index.js'", + expect(tuiCommand("/usr/bin/node", "/opt/torhunt/dist/index.js")).toBe( + "'/usr/bin/node' '/opt/torhunt/dist/index.js'", ); }); it("escapes single quotes in paths", () => { diff --git a/src/daemon/attach.ts b/src/daemon/attach.ts index 8ac9631..fb01bbc 100644 --- a/src/daemon/attach.ts +++ b/src/daemon/attach.ts @@ -1,7 +1,7 @@ -// Detach / reattach for the TUI, the simple + stable way: run torlink inside a -// persistent tmux session. `torlnk attach` creates the session (or reattaches to +// Detach / reattach for the TUI, the simple + stable way: run torhunt inside a +// persistent tmux session. `torhunt attach` creates the session (or reattaches to // it if it's already running), so you can detach with tmux's ctrl-b d, log out, -// log back in over ssh/mosh, and `torlnk attach` again to pick up right where +// log back in over ssh/mosh, and `torhunt attach` again to pick up right where // you left off. tmux does the heavy lifting, so this stays tiny. import { spawnSync } from "node:child_process"; @@ -29,7 +29,7 @@ export function tuiCommand(execPath: string, scriptPath: string): string { export function runAttach(): never { if (!hasTmux()) { console.error( - "torlink attach needs tmux (for detach/reattach). Install tmux, or just run `torlnk`.", + "torhunt attach needs tmux (for detach/reattach). Install tmux, or just run `torhunt`.", ); process.exit(1); } diff --git a/src/daemon/daemonize.ts b/src/daemon/daemonize.ts index 303f9b6..52748e4 100644 --- a/src/daemon/daemonize.ts +++ b/src/daemon/daemonize.ts @@ -3,7 +3,7 @@ // a pidfile plus a run descriptor, and exits the parent. You can then log out and // it keeps running. // -// The run descriptor is what lets `torlnk update` relaunch a daemon on its exact +// The run descriptor is what lets `torhunt update` relaunch a daemon on its exact // original command after rebuilding. // // NOTE: on a box with systemd, a `systemctl --user` service with linger is a @@ -15,7 +15,7 @@ import fs from "node:fs"; import path from "node:path"; import { logsDir } from "../config/paths"; -const MARKER = "TORLINK_DAEMONIZED"; +const MARKER = "TORHUNT_DAEMONIZED"; export function logPathFor(name: string): string { return path.join(logsDir, `${name}.log`); @@ -29,7 +29,7 @@ export function runPathFor(name: string): string { // Records argv and cwd only, not env: a daemon relaunched after an update // inherits the updater's environment, so env-dependent behavior (proxies, -// TORLINK_* overrides) follows the shell that ran `torlnk update`. +// TORHUNT_* overrides) follows the shell that ran `torhunt update`. export interface RunDescriptor { name: string; pid: number; @@ -70,7 +70,7 @@ export function daemonize(name: string): void { const logPath = logPathFor(name); const pidPath = pidPathFor(name); - console.log(`torlink ${name} daemon started (pid ${pid}).`); + console.log(`torhunt ${name} daemon started (pid ${pid}).`); console.log(` logs: ${logPath}`); console.log(` stop: kill ${pid} (or: kill $(cat ${pidPath}))`); process.exit(0); diff --git a/src/daemon/files.ts b/src/daemon/files.ts index 5e26cf9..f302994 100644 --- a/src/daemon/files.ts +++ b/src/daemon/files.ts @@ -97,7 +97,7 @@ export function parseRange(header: string | undefined, size: number): Range | nu } function log(message: string): void { - console.log(`[torlnk files] ${new Date().toISOString()} ${message}`); + console.log(`[torhunt files] ${new Date().toISOString()} ${message}`); } async function sendListing(res: http.ServerResponse, dir: string, method: string): Promise { @@ -179,7 +179,7 @@ export async function runFiles(options: FilesOptions = {}): Promise { if (!LOOPBACK_HOSTS.has(host) && !token) { console.error( `error: refusing to bind ${host} without a token. Pass --token ` + - `(or set TORLINK_FILES_TOKEN), or bind 127.0.0.1.`, + `(or set TORHUNT_FILES_TOKEN), or bind 127.0.0.1.`, ); process.exit(1); return; diff --git a/src/daemon/restart.test.ts b/src/daemon/restart.test.ts index 0ea2850..4039c51 100644 --- a/src/daemon/restart.test.ts +++ b/src/daemon/restart.test.ts @@ -17,7 +17,7 @@ describe("isAlive", () => { describe("listRunDescriptors", () => { let dir: string; beforeEach(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), "torlink-restart-")); + dir = fs.mkdtempSync(path.join(os.tmpdir(), "torhunt-restart-")); }); afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/daemon/runtime.test.ts b/src/daemon/runtime.test.ts index 25fc273..ae2a6ac 100644 --- a/src/daemon/runtime.test.ts +++ b/src/daemon/runtime.test.ts @@ -20,7 +20,7 @@ function fakeRuntime(dir: string, has = false): { runtime: Runtime; add: ReturnT describe("addInput", () => { let dir: string; beforeEach(async () => { - dir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-rt-")); + dir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-rt-")); }); afterEach(async () => { await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); diff --git a/src/daemon/runtime.ts b/src/daemon/runtime.ts index 9ae9385..ead0555 100644 --- a/src/daemon/runtime.ts +++ b/src/daemon/runtime.ts @@ -7,6 +7,7 @@ import { promises as fs } from "node:fs"; import { loadConfig } from "../config/config"; +import { resolveDownloadDir } from "../config/folder"; import { DownloadQueue } from "../download/queue"; import { loadQueue, loadSeeds } from "../download/persist"; import { loadHistory } from "../download/history"; @@ -44,7 +45,7 @@ export async function startRuntime(overrideDir?: string): Promise { queue.restoreSeeds(await loadSeeds(), { safe }); setTimeout(disarmBootMarker, BOOT_SETTLE_MS).unref(); if (safe) { - console.error("[torlnk] recovered from a crashed start: restored downloads are paused"); + console.error("[torhunt] recovered from a crashed start: restored downloads are paused"); } const downloadDir = overrideDir && overrideDir.trim() ? overrideDir.trim() : cfg.downloadDir; return { queue, downloadDir, recovered: safe }; diff --git a/src/daemon/serve.test.ts b/src/daemon/serve.test.ts index 092d09e..5e0537a 100644 --- a/src/daemon/serve.test.ts +++ b/src/daemon/serve.test.ts @@ -45,7 +45,7 @@ describe("handleApi", () => { let runtime: Runtime; beforeEach(async () => { - dir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-serve-")); + dir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-serve-")); add = vi.fn(); runtime = { queue: { diff --git a/src/daemon/serve.ts b/src/daemon/serve.ts index e5dcb33..6e7c828 100644 --- a/src/daemon/serve.ts +++ b/src/daemon/serve.ts @@ -1,4 +1,4 @@ -// Headless HTTP add API: torlnk exposes a tiny local server so another program +// Headless HTTP add API: torhunt exposes a tiny local server so another program // (a seedbox web app, a script, curl) can hand it a torrent over HTTP instead of // a keypress. It complements the watch folder — same headless runtime, a // different doorway. @@ -217,7 +217,7 @@ function readBody(req: http.IncomingMessage): Promise<{ text: string; tooLarge: } function log(message: string): void { - console.log(`[torlnk serve] ${new Date().toISOString()} ${message}`); + console.log(`[torhunt serve] ${new Date().toISOString()} ${message}`); } export async function runServe(options: ServeOptions = {}): Promise { @@ -229,7 +229,7 @@ export async function runServe(options: ServeOptions = {}): Promise { if (!LOOPBACK_HOSTS.has(host) && !token) { console.error( `error: refusing to bind ${host} without a token. Pass --token ` + - `(or set TORLINK_API_TOKEN), or bind 127.0.0.1.`, + `(or set TORHUNT_API_TOKEN), or bind 127.0.0.1.`, ); process.exit(1); return; diff --git a/src/daemon/watch.test.ts b/src/daemon/watch.test.ts index b754e3b..775b1d3 100644 --- a/src/daemon/watch.test.ts +++ b/src/daemon/watch.test.ts @@ -44,8 +44,8 @@ describe("processFile", () => { let runtime: Runtime; beforeEach(async () => { - dir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-watch-")); - downloadDir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-dl-")); + dir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-watch-")); + downloadDir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-dl-")); add = vi.fn(); runtime = { queue: { has: () => false, add } as unknown as Runtime["queue"], diff --git a/src/daemon/watch.ts b/src/daemon/watch.ts index 1389e19..1ef9b40 100644 --- a/src/daemon/watch.ts +++ b/src/daemon/watch.ts @@ -1,4 +1,4 @@ -// Headless watch-folder mode: torlnk watches a directory and downloads any +// Headless watch-folder mode: torhunt watches a directory and downloads any // torrent dropped into it. This is the "blackhole" pattern torrent clients use // so other tools (a seedbox web app, a script, curl) can hand off a torrent by // writing a file — no keypress, no TUI. Drop a `.torrent` file, or a `.magnet` @@ -36,7 +36,7 @@ export function firstMeaningfulLine(text: string): string | null { function log(message: string): void { const stamp = new Date().toISOString(); - console.log(`[torlnk watch] ${stamp} ${message}`); + console.log(`[torhunt watch] ${stamp} ${message}`); } async function moveInto(dir: string, sub: string, name: string): Promise { diff --git a/src/download/bootguard.test.ts b/src/download/bootguard.test.ts index 8794c12..18289c2 100644 --- a/src/download/bootguard.test.ts +++ b/src/download/bootguard.test.ts @@ -4,13 +4,13 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -// The marker lives at a fixed path derived from TORLINK_STATE_DIR at module +// The marker lives at a fixed path derived from TORHUNT_STATE_DIR at module // init, and other suites (persistSync tests) legitimately disarm it. Each test // here gets a private state dir + fresh module instances so parallel test // files can never race on the shared marker. async function isolated() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-bootguard-")); - vi.stubEnv("TORLINK_STATE_DIR", dir); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-bootguard-")); + vi.stubEnv("TORHUNT_STATE_DIR", dir); vi.resetModules(); const paths = await import("../config/paths"); const bootguard = await import("./bootguard"); diff --git a/src/download/persist.test.ts b/src/download/persist.test.ts index 6012a37..a3df1f1 100644 --- a/src/download/persist.test.ts +++ b/src/download/persist.test.ts @@ -19,7 +19,7 @@ describe("torrent metadata export", () => { it("copies cached .torrent metadata into the requested folder", async () => { const id = `export-${Date.now()}`; - const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-export-")); + const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-export-")); const data = new Uint8Array([1, 2, 3, 4]); try { await saveTorrentMeta(id, data); @@ -35,7 +35,7 @@ describe("torrent metadata export", () => { }); it("returns null when metadata has not arrived yet", async () => { - const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-export-missing-")); + const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-export-missing-")); try { await expect(exportTorrentMeta("missing", "Missing", outDir)).resolves.toBeNull(); } finally { diff --git a/src/download/queue.concurrency.test.ts b/src/download/queue.concurrency.test.ts index 44eded0..0b8ebf6 100644 --- a/src/download/queue.concurrency.test.ts +++ b/src/download/queue.concurrency.test.ts @@ -23,7 +23,7 @@ const mk = (n: number) => ({ const statuses = (q: DownloadQueue): Record => Object.fromEntries(q.getItems().map((i) => [i.id, i.status])); -describe("DownloadQueue concurrent-download cap (TORLINK_MAX_DOWNLOADS)", () => { +describe("DownloadQueue concurrent-download cap (TORHUNT_MAX_DOWNLOADS)", () => { it("queues torrents beyond the cap, then promotes the oldest when a slot frees", () => { const q = new DownloadQueue({ maxDownloads: 2 }); q.add(mk(1), "/d"); diff --git a/src/download/queue.test.ts b/src/download/queue.test.ts index ea8a34a..0fed174 100644 --- a/src/download/queue.test.ts +++ b/src/download/queue.test.ts @@ -49,7 +49,7 @@ describe("DownloadQueue seeding", () => { it("exports cached .torrent metadata for a history item", async () => { const q = new DownloadQueue(); - const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-queue-export-")); + const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-queue-export-")); const item = h({ id: "h5", name: "Some/Torrent", dir: outDir }); try { q.restoreHistory([item]); @@ -70,7 +70,7 @@ describe("DownloadQueue seeding", () => { describe("DownloadQueue.fetchAndExportTorrent", () => { it("exports cached metadata immediately, without touching the engine", async () => { const q = new DownloadQueue(); - const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-fetch-export-")); + const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-fetch-export-")); const fakeEngine = (q as unknown as { engine: { add: () => void } }).engine; fakeEngine.add = () => { throw new Error("must not touch the engine when metadata is cached"); @@ -96,7 +96,7 @@ describe("DownloadQueue.fetchAndExportTorrent", () => { it("skips a magnet already active in the queue instead of double-adding it", async () => { const q = new DownloadQueue(); - const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-fetch-export-")); + const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-fetch-export-")); const fakeEngine = (q as unknown as { engine: { add: () => void } }).engine; fakeEngine.add = () => { throw new Error("must not add a torrent that's already active in the queue"); @@ -120,7 +120,7 @@ describe("DownloadQueue.fetchAndExportTorrent", () => { it("fetches metadata over the network, tears the handle down immediately, then exports", async () => { const q = new DownloadQueue(); - const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-fetch-export-")); + const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-fetch-export-")); const removed: string[] = []; const fakeEngine = ( q as unknown as { @@ -161,7 +161,7 @@ describe("DownloadQueue.fetchAndExportTorrent", () => { it("resolves null and tears down the handle when the metadata fetch fails", async () => { const q = new DownloadQueue(); - const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-fetch-export-")); + const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-fetch-export-")); const removed: string[] = []; const fakeEngine = ( q as unknown as { @@ -194,7 +194,7 @@ describe("DownloadQueue.fetchAndExportTorrent", () => { it("gives up after a metadata timeout and tears the handle down", async () => { const q = new DownloadQueue(); - const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-fetch-export-")); + const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-fetch-export-")); const removed: string[] = []; const fakeEngine = ( q as unknown as { diff --git a/src/download/queue.ts b/src/download/queue.ts index 575a463..405fa83 100644 --- a/src/download/queue.ts +++ b/src/download/queue.ts @@ -49,7 +49,7 @@ const HISTORY_MAX = 500; // Max torrents allowed to actively download at once. Overflow waits as "queued" // and starts automatically when a slot frees. 0 / unset = unlimited (default). function readMaxDownloads(): number { - const v = Number(process.env.TORLINK_MAX_DOWNLOADS); + const v = Number(process.env.TORHUNT_MAX_DOWNLOADS); return Number.isFinite(v) && v > 0 ? Math.floor(v) : 0; } diff --git a/src/download/types.ts b/src/download/types.ts index 5b4639e..0ee32eb 100644 --- a/src/download/types.ts +++ b/src/download/types.ts @@ -1,6 +1,6 @@ import type { SourceId } from "../sources/types"; -// "queued" = waiting for a free download slot (see TORLINK_MAX_DOWNLOADS). Unlike +// "queued" = waiting for a free download slot (see TORHUNT_MAX_DOWNLOADS). Unlike // "paused" (an explicit user action) a queued item is started automatically as // soon as a slot frees. export type DownloadStatus = "downloading" | "queued" | "paused" | "completed" | "failed"; diff --git a/src/index.tsx b/src/index.tsx index 0a88d09..5a1a157 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -39,7 +39,7 @@ if (cmd.kind === "attach") { // Headless subcommands: run the download queue with no terminal UI (for // seedboxes and servers). Kept above the alt-screen setup below — these paths -// never touch the TUI. Each is dynamically imported so a plain `torlnk` launch +// never touch the TUI. Each is dynamically imported so a plain `torhunt` launch // pays nothing for them. function failHeadless(err: unknown): never { console.error(err instanceof Error ? err.message : String(err)); @@ -59,7 +59,7 @@ if (cmd.kind === "update") { const options = { port: cmd.port, host: cmd.host, - token: cmd.token ?? process.env.TORLINK_API_TOKEN, + token: cmd.token ?? process.env.TORHUNT_API_TOKEN, downloadDir: cmd.downloadDir, seedTimeMs: cmd.seedTimeMs, deleteFiles: cmd.deleteFiles, @@ -70,7 +70,7 @@ if (cmd.kind === "update") { const options = { port: cmd.port, host: cmd.host, - token: cmd.token ?? process.env.TORLINK_FILES_TOKEN, + token: cmd.token ?? process.env.TORHUNT_FILES_TOKEN, dir: cmd.dir, }; void import("./daemon/files").then(({ runFiles }) => runFiles(options).catch(failHeadless)); diff --git a/src/sources/bittorrented.ts b/src/sources/bittorrented.ts index 55f0e94..dfc5deb 100644 --- a/src/sources/bittorrented.ts +++ b/src/sources/bittorrented.ts @@ -3,7 +3,7 @@ import { buildMagnet } from "./magnet"; import type { SearchOptions, Source, SourceId, TorrentResult } from "./types"; // BitTorrented is a general index (its own library plus a large DHT crawl). -// torlink takes its video type only and feeds it to Movies and TV. Anime stays +// torhunt takes its video type only and feeds it to Movies and TV. Anime stays // with its dedicated sources (the API can't tell anime from any other video) // and Games stays FitGirl's alone. Its JSON API returns real swarm counts, so // reportsHealth is true. @@ -33,7 +33,7 @@ function toUnixSeconds(iso: string | undefined): number | undefined { return Number.isNaN(ms) ? undefined : Math.floor(ms / 1000); } -// Map the API rows to torlink results. Pure and exported so the mapping is tested +// Map the API rows to torhunt results. Pure and exported so the mapping is tested // without a live request. Rows without a valid 40-char info hash are dropped (a // magnet needs one). export function mapBittorrentedResults(results: BtResult[], id: SourceId): TorrentResult[] { diff --git a/src/sources/x1337.ts b/src/sources/x1337.ts index b65fdae..d02e2f7 100644 --- a/src/sources/x1337.ts +++ b/src/sources/x1337.ts @@ -6,7 +6,7 @@ import type { SearchOptions, Source, SourceId, TorrentResult } from "./types"; const HOSTS = ["1337x.to", "1337x.st", "x1337x.ws", "1337xx.to"]; let workingHostIndex = 0; -const MAX_DETAILS = 4; +const MAX_DETAILS = 16; const STOP = new Set(["the", "a", "an", "of", "and", "or", "to"]); @@ -70,7 +70,11 @@ async function detailInfo( opts: SearchOptions, ): Promise<{ magnet: string; added?: number } | null> { try { - const html = await fetchText(`${base}${path}`, opts, 1); + const detailSignal = + typeof AbortSignal.any === "function" && typeof AbortSignal.timeout === "function" + ? (opts.signal ? AbortSignal.any([opts.signal, AbortSignal.timeout(4000)]) : AbortSignal.timeout(4000)) + : opts.signal; + const html = await fetchText(`${base}${path}`, { ...opts, signal: detailSignal }, 0); const raw = html.match(/magnet:\?xt=urn:btih:[^"'<>\s]+/i)?.[0]; if (!raw) return null; return { magnet: unescapeEntities(raw), added: parseUploadDate(html) }; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index a4647cb..d227104 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -2,7 +2,7 @@ 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 { normalizeDownloadDir } from "../config/folder"; +import { normalizeDownloadDir, resolveDownloadDir } from "../config/folder"; import { DownloadQueue } from "../download/queue"; import { loadQueue, loadSeeds } from "../download/persist"; import { loadHistory } from "../download/history"; @@ -63,6 +63,7 @@ import { FolderPrompt } from "./components/FolderPrompt"; import { TrackersPrompt } from "./components/TrackersPrompt"; import { ThemePrompt } from "./components/ThemePrompt"; import { SpinnerPrompt } from "./components/SpinnerPrompt"; +import { QrModal } from "./components/QrModal"; import { footerHints } from "./keymap"; import { COLOR, ICON, DEFAULT_THEME, getTheme, nextTheme, type Theme } from "./theme"; import { DEFAULT_SPINNER, getSpinner, type SpinnerPreset } from "./spinnerPresets"; @@ -151,6 +152,7 @@ export function App({ sizeBytes?: number; } | null>(null); const [lastDownloadToDir, setLastDownloadToDir] = useState(null); + const [qrModalItem, setQrModalItem] = useState<{ name: string; magnet: string } | null>(null); const [notice, setNotice] = useState(null); const [updateVersion, setUpdateVersion] = useState(null); const [recovered, setRecovered] = useState(false); @@ -164,6 +166,30 @@ export function App({ const cfg = await loadConfig(); const q = new DownloadQueue(); q.setTrackers(cfg.trackers); + + const onStarted = (name: string): void => { + if (cfg.notifyOnComplete ?? true) { + sendNotification("torhunt — Download Started", cleanText(name)); + } + }; + q.on("started", onStarted); + + const onCompleted = (name: string): void => { + setNotice(`${ICON.done} ${truncate(cleanText(name), 40)}`); + if (cfg.notifyOnComplete ?? true) { + sendNotification("torhunt — Download Complete", cleanText(name)); + } + if (q.activeCount === 0 && q.getItems().length === 0) { + releaseKeepAwake(); + if (cfg.onComplete === "sleep") { + triggerSleep(); + } else if (cfg.onComplete === "shutdown") { + triggerShutdown(); + } + } + }; + q.on("completed", onCompleted); + // Crash-boot breaker: a marker left behind by the previous boot means it // died mid-restore, so this one restores everything paused with the // engine cold (safe mode) instead of walking into the same explosion. @@ -204,10 +230,13 @@ export function App({ ? await magnetFromTorrentFile(initialTorrent) : null; if (launch) { - await fs.mkdir(cfg.downloadDir, { recursive: true }).catch(() => {}); + const targetDir = resolveDownloadDir(cfg.downloadDir, { + categorySubfolders: cfg.categorySubfolders ?? true, + }); + await fs.mkdir(targetDir, { recursive: true }).catch(() => {}); q.add( { id: launch.infoHash, name: launch.name, magnet: launch.magnet }, - cfg.downloadDir, + targetDir, ); setView("browser"); setSection("downloads"); @@ -222,7 +251,7 @@ export function App({ // Best-effort, once per launch, off the hot path: if a newer release exists, // surface a quiet banner. Any failure (offline, opt-out) just leaves it hidden. useEffect(() => { - if (process.env.TORLINK_NO_UPDATE_CHECK) return; + if (process.env.TORHUNT_NO_UPDATE_CHECK) return; let alive = true; void (async () => { const latest = await fetchLatestVersion(); @@ -248,33 +277,8 @@ export function App({ updatePowerState(); queue.on("change", updatePowerState); - const onStarted = (name: string): void => { - if (config.notifyOnComplete ?? true) { - sendNotification("torhunt — Download Started", cleanText(name)); - } - }; - queue.on("started", onStarted); - - const onCompleted = (name: string): void => { - setNotice(`${ICON.done} ${truncate(cleanText(name), 40)}`); - if (config.notifyOnComplete ?? true) { - sendNotification("torhunt — Download Complete", cleanText(name)); - } - if (queue.activeCount === 0 && queue.getItems().length === 0) { - releaseKeepAwake(); - if (config.onComplete === "sleep") { - triggerSleep(); - } else if (config.onComplete === "shutdown") { - triggerShutdown(); - } - } - }; - queue.on("completed", onCompleted); - return () => { queue.off("change", updatePowerState); - queue.off("started", onStarted); - queue.off("completed", onCompleted); releaseKeepAwake(); }; }, [queue, config]); @@ -397,8 +401,12 @@ export function App({ sizeBytes?: number; }) => { if (!config || !queue) return; - void fs.mkdir(config.downloadDir, { recursive: true }).catch(() => {}); - queue.add(input, config.downloadDir); + const targetDir = resolveDownloadDir(config.downloadDir, { + source: input.source, + categorySubfolders: config.categorySubfolders ?? true, + }); + void fs.mkdir(targetDir, { recursive: true }).catch(() => {}); + queue.add(input, targetDir); setNotice(`Added: ${truncate(cleanText(input.name), 40)}`); setSection("downloads"); setRegion("content"); @@ -427,8 +435,12 @@ export function App({ (raw: string) => { const input = pendingDownload; setPendingDownload(null); - const dir = normalizeDownloadDir(raw); - if (!queue || !input || !dir) return; + const baseDir = normalizeDownloadDir(raw); + if (!queue || !input || !baseDir || !config) return; + const targetDir = resolveDownloadDir(baseDir, { + source: input.source, + categorySubfolders: config.categorySubfolders ?? true, + }); // add() ignores the dir for anything already active, so don't claim a // folder that won't be used. Failed items fall through: a re-add with a // fresh dir is exactly how a bad-disk download gets redirected. @@ -439,19 +451,19 @@ export function App({ } void (async () => { try { - await fs.mkdir(dir, { recursive: true }); + await fs.mkdir(targetDir, { recursive: true }); } catch { - setNotice(`Couldn't use folder: ${truncate(dir, 48)}`); + setNotice(`Couldn't use folder: ${truncate(targetDir, 48)}`); return; } - setLastDownloadToDir(dir); - queue.add(input, dir); - setNotice(`Added: ${truncate(cleanText(input.name), 28)} → ${truncate(dir, 36)}`); + setLastDownloadToDir(targetDir); + queue.add(input, targetDir); + setNotice(`Added: ${truncate(cleanText(input.name), 28)} → ${truncate(targetDir, 36)}`); setSection("downloads"); setRegion("content"); })(); }, - [queue, pendingDownload], + [queue, pendingDownload, config], ); const copyMagnet = useCallback((input: { name: string; magnet: string }) => { @@ -649,7 +661,8 @@ export function App({ editingTrackers || editingTheme || editingSpinner || - pendingDownload + pendingDownload || + qrModalItem ? "help" : region, setRegion, @@ -672,6 +685,7 @@ export function App({ openThemePicker: () => setEditingTheme(true), openSpinnerPicker: () => setEditingSpinner(true), openFolderPicker: () => setEditingFolder(true), + openQrModal: (item: { name: string; magnet: string }) => setQrModalItem(item), searchModeTrigger, triggerSearch, bookmarks, @@ -794,6 +808,10 @@ export function App({ } if (key.escape) { if (captureMode === "esc") return; + if (qrModalItem) { + setQrModalItem(null); + return; + } if (region === "content") { setRegion("sidebar"); return; @@ -921,6 +939,21 @@ export function App({ ) : null} + {qrModalItem ? ( + + setQrModalItem(null)} + onCopy={() => { + writeClipboard(qrModalItem.magnet); + setNotice("✓ Copied magnet link to clipboard"); + }} + /> + + ) : null} + 0 ? `- ${total}` : undefined} height={panelH} > diff --git a/src/ui/components/CompletedView.tsx b/src/ui/components/CompletedView.tsx index 1ef6e01..0f0eec6 100644 --- a/src/ui/components/CompletedView.tsx +++ b/src/ui/components/CompletedView.tsx @@ -127,7 +127,7 @@ export function CompletedView() { title="completed downloads" width={contentWidth} focused={focused} - count={`(${total})`} + count={total > 0 ? `- ${total}` : undefined} height={panelH} > @@ -144,11 +144,12 @@ export function CompletedView() { {visibleLines.map((row, i) => { if (row.type === "header") { + const prefix = `── ${row.title} `; + const rest = Math.max(0, contentWidth - 4 - prefix.length); return ( 0 ? 1 : 0}> - - {`── ${row.title} ────────────────────────────────────────────────────────────`} - + {prefix} + {"─".repeat(rest)} ); } diff --git a/src/ui/components/Downloads.tsx b/src/ui/components/Downloads.tsx index 7c59292..4a12291 100644 --- a/src/ui/components/Downloads.tsx +++ b/src/ui/components/Downloads.tsx @@ -53,6 +53,7 @@ export function Downloads() { openDownloadFolder, setDownloadFocus, exportTorrent, + openQrModal, theme, } = useStore(); const active = useQueueItems(queue); @@ -84,6 +85,9 @@ export function Downloads() { } else if (input === "s") { const item = active[clamped]; if (item) exportTorrent({ id: item.id, name: item.name }); + } else if (input === "Q") { + const item = active[clamped]; + if (item?.magnet) openQrModal({ name: item.name, magnet: item.magnet }); } else { const it = active[clamped]; if (!it) return; @@ -132,10 +136,10 @@ export function Downloads() { 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 statsW = Math.max(6, inner - MARK - GUTTER - 1 - barW - gap); return ( - + 0 ? `- ${active.length}` : undefined} height={panelH}> {activeVisible.map((it, i) => { const here = activeStart + i === clamped && focused; const sc = statusColor(it.status, theme); @@ -151,7 +155,7 @@ export function Downloads() { {statusIcon(it.status)} - + - + diff --git a/src/ui/components/HeaderBar.tsx b/src/ui/components/HeaderBar.tsx index 1bdb8d7..1f72ddd 100644 --- a/src/ui/components/HeaderBar.tsx +++ b/src/ui/components/HeaderBar.tsx @@ -18,7 +18,9 @@ export function HeaderBar({ width }: { width: number }) { .filter((s) => s.status === "seeding") .reduce((acc, s) => acc + s.uploadSpeed, 0); - const showStats = width >= 75; + const hasActivity = activeCount > 0 || seedingCount > 0 || downSpeed > 0 || upSpeed > 0; + const showFullStats = width >= 80; + const showCompactStats = width >= 58 && !showFullStats; return ( + {/* Left: Branding Logo */} - + {/* Center & Right: Notifications & Telemetry */} + {notice ? ( - - + + {notice} ) : null} - {showStats ? ( + {showFullStats ? ( - {/* Active downloads / seeding pill */} - - 0 ? theme.colors.accent : undefined} dimColor={activeCount === 0}> - {`dl: ${activeCount}`} + {/* Speeds */} + + 0 ? theme.colors.good : undefined} dimColor={downSpeed === 0} bold={downSpeed > 0}> + {`${ICON.down} ${formatBytesPerSec(downSpeed) || "0 B/s"}`} - {` ${ICON.dot} `} - 0 ? theme.colors.good : undefined} dimColor={seedingCount === 0}> - {`seed: ${seedingCount}`} + {` `} + 0 ? theme.colors.alt : undefined} dimColor={upSpeed === 0} bold={upSpeed > 0}> + {`${ICON.up} ${formatBytesPerSec(upSpeed) || "0 B/s"}`} - {/* Speeds */} - {(downSpeed > 0 || upSpeed > 0) ? ( - - + {/* Subtle Vertical Divider */} + + + + + {/* Active Torrent Counters */} + + 0 ? theme.colors.accent : undefined} + dimColor={activeCount === 0} + bold={activeCount > 0} + > + {`${activeCount} dl`} + + {` ${ICON.dot} `} + 0 ? (theme.colors.sprout || theme.colors.good) : undefined} + dimColor={seedingCount === 0} + bold={seedingCount > 0} + > + {`${seedingCount} seed`} + + + + ) : showCompactStats && hasActivity ? ( + + {downSpeed > 0 || upSpeed > 0 ? ( + + 0}> {`${ICON.down} ${formatBytesPerSec(downSpeed)}`} - {` `} - - {`${ICON.up} ${formatBytesPerSec(upSpeed)}`} + {upSpeed > 0 ? ( + <> + {` `} + + {`${ICON.up} ${formatBytesPerSec(upSpeed)}`} + + + ) : null} + + ) : ( + + 0 ? theme.colors.accent : undefined} dimColor={activeCount === 0}> + {`${activeCount} dl`} + + {` ${ICON.dot} `} + 0 ? theme.colors.good : undefined} dimColor={seedingCount === 0}> + {`${seedingCount} seed`} - ) : null} + )} ) : null} diff --git a/src/ui/components/QrModal.tsx b/src/ui/components/QrModal.tsx new file mode 100644 index 0000000..c5f4027 --- /dev/null +++ b/src/ui/components/QrModal.tsx @@ -0,0 +1,132 @@ +import { useMemo } from "react"; +import { Box, Text, useInput } from "ink"; +import { compactMagnet, encodeQrMatrix, renderQrToTerminal } from "../../util/qrcode"; +import { cleanText, truncate } from "../../util/format"; +import { DEFAULT_THEME, type Theme } from "../theme"; +import { useStore } from "../store"; + +interface QrModalProps { + name: string; + magnet: string; + width: number; + onClose: () => void; + onCopy?: () => void; +} + +export function QrModal({ name, magnet, width, onClose, onCopy }: QrModalProps) { + const store = useStore(); + const theme: Theme = store?.theme ?? DEFAULT_THEME; + + const targetMagnet = useMemo(() => compactMagnet(magnet), [magnet]); + + const qrLines = useMemo(() => { + try { + const matrix = encodeQrMatrix(targetMagnet); + return renderQrToTerminal(matrix); + } catch { + return []; + } + }, [targetMagnet]); + + useInput( + (input, key) => { + // Esc exclusively closes the modal banner + if (key.escape) { + onClose(); + return; + } + if (input === "c" || input === "C") { + onCopy?.(); + return; + } + }, + { isActive: true }, + ); + + const w = Math.max(40, width); + const borderCol = theme.colors.accent; + const titleCol = theme.colors.bright; + + return ( + + {/* Top Header Border */} + + {"┌─[ "} + + MOBILE QR HAND-OFF + + {` ]${"─".repeat(Math.max(0, w - 26))}┐`} + + + {/* Main Content Area */} + + {/* Left: QR Code Box */} + + {qrLines.map((line, i) => ( + + {line} + + ))} + + + {/* Right: Metadata & Actions */} + + {/* Release Name */} + + + {cleanText(name)} + + + + {/* Subtitle Instructions */} + + + Scan with your phone camera to start downloading instantly + + + + {/* Magnet Preview */} + + + {targetMagnet} + + + + {/* Action Key Pills */} + + + [c] + Copy magnet link + + + [Esc] + Close + + + + + + {/* Bottom Footer Border */} + + {`└${"─".repeat(Math.max(0, w - 2))}┘`} + + + ); +} diff --git a/src/ui/components/Results.test.tsx b/src/ui/components/Results.test.tsx index 38eded2..aaba2e7 100644 --- a/src/ui/components/Results.test.tsx +++ b/src/ui/components/Results.test.tsx @@ -63,7 +63,7 @@ async function mount(results: TorrentResult[] = LIST): Promise { , ); const u = ui; - await vi.waitFor(() => expect(u.frame()).toContain(`RESULTS (${results.length})`)); + await vi.waitFor(() => expect(u.frame()).toContain(`RESULTS - ${results.length}`)); return u; } @@ -80,7 +80,7 @@ async function openFilter(u: RenderedUI): Promise { async function type(u: RenderedUI, text: string, expectCount: number): Promise { u.press(text); - await vi.waitFor(() => expect(u.frame()).toContain(`(${expectCount})`)); + await vi.waitFor(() => expect(u.frame()).toContain(`- ${expectCount}`)); } describe("Results filter UI", () => { @@ -101,7 +101,7 @@ describe("Results filter UI", () => { // The bug this guards against: the bar rendered as a row sibling of the // panel, landing on the top border line and squeezing the title. - expect(ls[top]).toContain("RESULTS (3)"); + expect(ls[top]).toContain("RESULTS - 3"); expect(ls[top]).toHaveLength(TEST_CONTENT_WIDTH); expect(bar).toBeGreaterThan(lastBorder); for (const l of ls) expect(l.length).toBeLessThanOrEqual(TEST_CONTENT_WIDTH); @@ -144,7 +144,7 @@ describe("Results filter UI", () => { u.press(KEY.esc); await vi.waitFor(() => expect(editing(u)).toBe(false)); expect(u.frame()).toContain("FILTER → iso"); - expect(u.frame()).toContain("(6)"); + expect(u.frame()).toContain("- 6"); u.press("j"); await vi.waitFor(() => { @@ -158,10 +158,10 @@ describe("Results filter UI", () => { await openFilter(u); await type(u, "arch", 1); u.press(KEY.ctrlU); - await vi.waitFor(() => expect(u.frame()).toContain("(8)")); + await vi.waitFor(() => expect(u.frame()).toContain("- 8")); u.press(KEY.enter); await vi.waitFor(() => expect(u.frame()).not.toContain("FILTER")); - expect(u.frame()).toContain("RESULTS (8)"); + expect(u.frame()).toContain("RESULTS - 8"); }); it("a zero-match filter never traps the user", async () => { @@ -179,9 +179,9 @@ describe("Results filter UI", () => { // Wait between keys: TextField's input closure only refreshes on render, // so a same-batch ctrl+u + enter would still submit the pre-clear value // (pre-existing TextField trait, logged as a follow-up). - await vi.waitFor(() => expect(u.frame()).toContain("RESULTS (8)")); + await vi.waitFor(() => expect(u.frame()).toContain("RESULTS - 8")); u.press(KEY.enter); await vi.waitFor(() => expect(u.frame()).not.toContain("FILTER")); - expect(u.frame()).toContain("RESULTS (8)"); + expect(u.frame()).toContain("RESULTS - 8"); }); }); diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx index 0400a3f..3b1d876 100644 --- a/src/ui/components/Results.tsx +++ b/src/ui/components/Results.tsx @@ -11,7 +11,7 @@ import { getSource, SOURCES } from "../../sources/registry"; import { stickCursor, wrapStep, windowStart, resultsPanelOuter } from "../move"; import { sortResults, nextSort, sortLabel, sortArrow, type Sort, type SortField } from "../sort"; import { filterResults } from "../filter"; -import { COLOR, GUTTER, ICON, sourceStyle, type Theme } from "../theme"; +import { GUTTER, ICON, sourceStyle, type Theme } from "../theme"; import { cleanText, formatBytes, formatCount, formatRelative, stripControl, truncate } from "../../util/format"; import { QUALITY_TAGS, type QualityTag } from "../../util/tags"; import { QualityFilterBar } from "./QualityFilterBar"; @@ -135,6 +135,7 @@ export function Results() { theme, searchModeTrigger, addBookmark, + openQrModal, } = useStore(); const search = useConcurrentSearch(query); @@ -268,13 +269,10 @@ export function Results() { setHideDead((h) => !h); return; } - if (input === "q") { - const options: (QualityTag | "ALL")[] = ["ALL", ...QUALITY_TAGS]; - setQualityTag((curr) => { - const idx = options.indexOf(curr); - const next = options[(idx + 1) % options.length]; - return next ?? "ALL"; - }); + if (input === "Q") { + if (results[clamped]?.magnet) { + openQrModal({ name: results[clamped]!.name, magnet: results[clamped]!.magnet }); + } return; } if (input === "1") { setQualityTag("ALL"); return; } @@ -364,7 +362,9 @@ export function Results() { const activeCat = CATEGORIES.find((c) => c.key === section); const status = (): ReactNode => { - if (search.loading) return ; + if (search.loading && results.length === 0) { + return ; + } const head = browsing ? `Latest from ${activeCat?.label ?? "all categories"}` : `Found ${results.length} result${results.length === 1 ? "" : "s"}`; @@ -377,7 +377,7 @@ export function Results() { const tabErrored = tabSources.every((s) => search.perSource[s.id]?.error); if (search.total === 0) { return ( - + No sources enabled for this tab. ); @@ -386,7 +386,7 @@ export function Results() { const down = tabSources.filter((s) => search.perSource[s.id]?.error); const who = down.length === 1 ? "The source" : `All ${down.length} sources`; return ( - + {`Couldn't reach ${activeCat.label}. ${who} may be down.`} ); @@ -397,8 +397,8 @@ export function Results() { ); } - const note = erroredCount > 0 ? ` (${erroredCount} source${erroredCount === 1 ? "" : "s"} down)` : ""; - return {`${head}${note}${sortNote}${filterNote}`}; + const loadingNote = search.loading ? ` · Streaming ${search.done}/${search.total}…` : ""; + return {`${head}${sortNote}${filterNote}${loadingNote}`}; }; const showStats = useMemo( @@ -419,7 +419,7 @@ export function Results() { const start = windowStart(clamped, results.length, listHeight); const visible = results.slice(start, start + listHeight); - const count = results.length > 0 ? `(${results.length})` : undefined; + const count = results.length > 0 ? `- ${results.length}` : undefined; return ( @@ -453,27 +453,27 @@ export function Results() { - # + # - NAME + NAME {showStats ? ( <> - {sortMark("size", "SIZE")} + {sortMark("size", "SIZE")} - {sortMark("seeders", "S:L")} + {sortMark("seeders", "S:L")} ) : ( - ADDED + ADDED )} - {sortMark("source", "SRC")} + {sortMark("source", "SRC")} ) : null} @@ -487,7 +487,7 @@ export function Results() { {here ? ICON.pointer : " "} - {index + 1} + {index + 1} 0 }, @@ -104,12 +107,12 @@ export function Seeding() { title="seeding" width={contentWidth} focused={focused} - count={seedingCount > 0 ? `(${seedingCount})` : undefined} + count={seedingCount > 0 ? `- ${seedingCount}` : undefined} height={panelH} > {seedingCount > 0 ? ( - + {ICON.up} {formatBytesPerSec(totalUp) || "0 B/s"} {` ${ICON.dot} ${totalPeers} peers ${ICON.dot} ${formatBytes(totalShared)} shared back`} @@ -123,16 +126,16 @@ export function Seeding() { - Name + Name - Size + Size - Status + Status - Src + Src diff --git a/src/ui/components/SettingsView.test.tsx b/src/ui/components/SettingsView.test.tsx index 8a627b6..77bebb1 100644 --- a/src/ui/components/SettingsView.test.tsx +++ b/src/ui/components/SettingsView.test.tsx @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { VERSION } from "../../version"; import { StoreContext } from "../store"; import { fakeQueue, makeTestStore, renderUI } from "../testHarness"; import { SettingsView } from "./SettingsView"; @@ -21,7 +22,7 @@ describe("SettingsView", () => { expect(ui.frame()).toContain("Stay Awake:"); expect(ui.frame()).toContain("Desktop Alerts:"); expect(ui.frame()).toContain("On Queue Finish:"); - expect(ui.frame()).toContain("v1."); + expect(ui.frame()).toContain(`v${VERSION}`); ui.unmount(); }); diff --git a/src/ui/components/SettingsView.tsx b/src/ui/components/SettingsView.tsx index a62c679..fc336e6 100644 --- a/src/ui/components/SettingsView.tsx +++ b/src/ui/components/SettingsView.tsx @@ -35,6 +35,7 @@ export function SettingsView() { const SETTING_ITEMS = [ { id: "downloadDir", label: "Download Folder" }, + { id: "categorySubfolders", label: "Category Subfolders" }, { id: "theme", label: "Color Theme" }, { id: "spinner", label: "Spinner Loader" }, { id: "preventSleep", label: "Stay Awake" }, @@ -42,6 +43,18 @@ export function SettingsView() { { id: "onComplete", label: "When Finished" }, ]; + const toggleCategorySubfolders = () => { + const nextVal = !(config.categorySubfolders ?? true); + const nextCfg = { ...config, categorySubfolders: nextVal }; + setConfig(nextCfg); + saveConfig(nextCfg); + setNotice( + nextVal + ? "Category Subfolders enabled: Downloads organized in Movies, TV, Anime, Games subfolders" + : "Category Subfolders disabled: Downloads saved directly in root download folder", + ); + }; + const togglePreventSleep = () => { const nextVal = !(config.preventSleep ?? true); const nextCfg = { ...config, preventSleep: nextVal }; @@ -95,6 +108,8 @@ export function SettingsView() { if (item?.id === "downloadDir") { setEditingPath(true); setCaptureMode("text"); + } else if (item?.id === "categorySubfolders") { + toggleCategorySubfolders(); } else if (item?.id === "theme") { openThemePicker(); } else if (item?.id === "spinner") { @@ -149,11 +164,25 @@ export function SettingsView() { - {/* Item 1: Theme */} + {/* Item 1: Category Subfolders */} - {selectedIdx === 1 && focused ? "→ " : " "}Color Theme: + {selectedIdx === 1 && focused ? "→ " : " "}Category Subfolders: + + + + + {(config.categorySubfolders ?? true) ? "[Enabled]" : "[Disabled]"} + + + + + {/* Item 2: Theme */} + + + + {selectedIdx === 2 && focused ? "→ " : " "}Color Theme: @@ -163,11 +192,11 @@ export function SettingsView() { - {/* Item 2: Spinner */} + {/* Item 3: Spinner */} - - {selectedIdx === 2 && focused ? "→ " : " "}Spinner Style: + + {selectedIdx === 3 && focused ? "→ " : " "}Spinner Style: @@ -177,11 +206,11 @@ export function SettingsView() { - {/* Item 3: Stay Awake */} + {/* Item 4: Stay Awake */} - - {selectedIdx === 3 && focused ? "→ " : " "}Stay Awake: + + {selectedIdx === 4 && focused ? "→ " : " "}Stay Awake: @@ -191,11 +220,11 @@ export function SettingsView() { - {/* Item 4: Desktop Alerts */} + {/* Item 5: Desktop Alerts */} - - {selectedIdx === 4 && focused ? "→ " : " "}Desktop Alerts: + + {selectedIdx === 5 && focused ? "→ " : " "}Desktop Alerts: @@ -205,11 +234,11 @@ export function SettingsView() { - {/* Item 5: On Complete */} + {/* Item 6: On Complete */} - - {selectedIdx === 5 && focused ? "→ " : " "}On Queue Finish: + + {selectedIdx === 6 && focused ? "→ " : " "}On Queue Finish: @@ -234,7 +263,7 @@ export function SettingsView() { - Press ↵ to edit folder, pick theme/spinner, toggle stay awake/alerts, or cycle finish action. + Press ↵ to edit folder, toggle category folders, pick theme/spinner, or set alerts/actions. diff --git a/src/ui/components/Sidebar.tsx b/src/ui/components/Sidebar.tsx index db5156a..cc5c2c1 100644 --- a/src/ui/components/Sidebar.tsx +++ b/src/ui/components/Sidebar.tsx @@ -69,7 +69,7 @@ export function Sidebar() { 0 ? 1 : 0}> {group.title ? ( - + {group.title} diff --git a/src/ui/components/UpdateBanner.tsx b/src/ui/components/UpdateBanner.tsx index f317c13..be79a27 100644 --- a/src/ui/components/UpdateBanner.tsx +++ b/src/ui/components/UpdateBanner.tsx @@ -1,7 +1,7 @@ import { Text } from "ink"; // A quiet one-liner above the wordmark when a newer release exists. Passive by -// design: it never steals focus or a key, it just points at `torlnk update`. +// design: it never steals focus or a key, it just points at `torhunt update`. export function UpdateBanner({ latest }: { latest: string | null }) { if (!latest) return null; return {`↑ torhunt v${latest} available · torhunt update`}; diff --git a/src/ui/helpLayout.test.ts b/src/ui/helpLayout.test.ts index 9982049..197ff2a 100644 --- a/src/ui/helpLayout.test.ts +++ b/src/ui/helpLayout.test.ts @@ -3,16 +3,16 @@ import { MEASURED, pickLayout } from "./helpLayout"; describe("help layout measurement", () => { it("derives packing widths and grid heights from HELP_GROUPS", () => { - expect(MEASURED.map((m) => m.width)).toEqual([140, 114, 77, 41]); - expect(MEASURED.map((m) => m.gridH)).toEqual([12, 17, 23, 36]); + expect(MEASURED.map((m) => m.width)).toEqual([136, 110, 77, 41]); + expect(MEASURED.map((m) => m.gridH)).toEqual([13, 18, 24, 37]); }); it("picks the widest packing that fits inside cols - 2", () => { expect(pickLayout(160).layout).toHaveLength(4); - expect(pickLayout(142).layout).toHaveLength(4); - expect(pickLayout(141).layout).toHaveLength(3); - expect(pickLayout(116).layout).toHaveLength(3); - expect(pickLayout(115).layout).toHaveLength(2); + expect(pickLayout(138).layout).toHaveLength(4); + expect(pickLayout(137).layout).toHaveLength(3); + expect(pickLayout(112).layout).toHaveLength(3); + expect(pickLayout(111).layout).toHaveLength(2); expect(pickLayout(80).layout).toHaveLength(2); expect(pickLayout(79).layout).toHaveLength(2); expect(pickLayout(78).layout).toHaveLength(1); diff --git a/src/ui/hooks/useConcurrentSearch.ts b/src/ui/hooks/useConcurrentSearch.ts index d41c059..a744b6d 100644 --- a/src/ui/hooks/useConcurrentSearch.ts +++ b/src/ui/hooks/useConcurrentSearch.ts @@ -39,7 +39,7 @@ function dedupe(list: TorrentResult[]): TorrentResult[] { return [...byHash.values()]; } -// torlink's default ordering: healthiest first. The results view can re-sort +// torhunt's default ordering: healthiest first. The results view can re-sort // on demand (the `s` key), and its "none"/default state preserves this order. function defaultOrder(list: TorrentResult[]): TorrentResult[] { return list.sort((a, b) => { @@ -65,6 +65,11 @@ function idleState(): ConcurrentSearchState { // leading-throttle the queue hooks in store.ts use for `update` events. const RESULT_FLUSH_MS = 150; +// Cap the maximum time any single source can take before timing out gracefully. +// Fast sources finish in <500ms; a 15s cap gives multi-step scrapers full room +// to complete without hanging indefinitely. +const SOURCE_TIMEOUT_MS = 15_000; + export function useConcurrentSearch(query: string): ConcurrentSearchState { const [state, setState] = useState(idleState); @@ -113,7 +118,12 @@ export function useConcurrentSearch(query: string): ConcurrentSearchState { }); for (const source of SOURCES) { - cachedSearch(source, query, { signal: ctrl.signal }) + const sourceSignal = + typeof AbortSignal.any === "function" && typeof AbortSignal.timeout === "function" + ? AbortSignal.any([ctrl.signal, AbortSignal.timeout(SOURCE_TIMEOUT_MS)]) + : ctrl.signal; + + cachedSearch(source, query, { signal: sourceSignal }) .then((res) => { if (!alive) return; collected.push(...res); diff --git a/src/ui/keymap.ts b/src/ui/keymap.ts index 0e2cfaa..0a2d695 100644 --- a/src/ui/keymap.ts +++ b/src/ui/keymap.ts @@ -29,13 +29,14 @@ export const HELP_GROUPS: HelpGroup[] = [ title: "Search", hints: [ { keys: "/", label: "Edit search" }, - { keys: "q / 1-7", label: "Quality filter (1-7)" }, + { keys: "1-7", label: "Quality filter" }, { keys: "f", label: "Filter list" }, { keys: "d", label: "Download (shift+d: folder)" }, { keys: "b", label: "Bookmark for later" }, { keys: "s", label: "Sort results" }, { keys: "z", label: "Hide dead torrents" }, { keys: "y", label: "Copy magnet" }, + { keys: "Q", label: "Mobile QR code" }, { keys: "↵", label: "Open details" }, { keys: "e", label: "Export as .torrent" }, { keys: "m", label: "Paste magnet" }, @@ -165,7 +166,8 @@ export function footerHints( NAVIGATE, { keys: "d", label: "Download" }, { keys: "b", label: "Bookmark" }, - { keys: "q", label: "Quality" }, + { keys: "1-7", label: "Quality" }, + { keys: "Q", label: "QR" }, resultFocus === "detail" ? EXPORT : { keys: "s", label: "Sort" }, { keys: "/", label: "Search" }, { keys: "f", label: "Filter" }, diff --git a/src/ui/store.ts b/src/ui/store.ts index fbd821a..701ef2c 100644 --- a/src/ui/store.ts +++ b/src/ui/store.ts @@ -93,6 +93,7 @@ export interface Store { openThemePicker: () => void; openSpinnerPicker: () => void; openFolderPicker: () => void; + openQrModal: (item: { name: string; magnet: string }) => void; searchModeTrigger: number; triggerSearch: () => void; diff --git a/src/ui/testHarness.ts b/src/ui/testHarness.ts index 73ad684..0036c3b 100644 --- a/src/ui/testHarness.ts +++ b/src/ui/testHarness.ts @@ -144,7 +144,8 @@ export function makeTestStore(overrides: Partial = {}): Store { const noop = (): void => {}; return { config: { - downloadDir: "~/Downloads/torlink", + downloadDir: "~/Downloads/torhunt", + categorySubfolders: true, theme: "electric-cyan", spinner: "dots", trackers: [], @@ -186,6 +187,7 @@ export function makeTestStore(overrides: Partial = {}): Store { openThemePicker: noop, openSpinnerPicker: noop, openFolderPicker: noop, + openQrModal: noop, searchModeTrigger: 0, triggerSearch: noop, bookmarks: [], diff --git a/src/ui/theme.ts b/src/ui/theme.ts index b7df1d1..b7a50ce 100644 --- a/src/ui/theme.ts +++ b/src/ui/theme.ts @@ -194,7 +194,7 @@ export const THEMES: readonly Theme[] = [ sourceStyles: { subsplease: { color: "#cbd5e1" }, "tpb-movies": { color: "#94a3b8" }, - "tpb-tv": { color: "#94e2d5" }, + "tpb-tv": { color: "#94a3b8" }, "x1337-movies": { color: "#f59e0b" }, "x1337-tv": { color: "#f59e0b" }, bittorrented: { color: "#38bdf8" }, @@ -413,7 +413,7 @@ export const THEMES: readonly Theme[] = [ text: "#ebdbb2", alt: "#fe8019", good: "#b8bb26", - warn: "#fabd2f", + warn: "#d79921", bad: "#fb4934", bright: "#fbf1c7", rule: "#3c3836", @@ -442,7 +442,7 @@ export const THEMES: readonly Theme[] = [ text: "#fcfcfa", alt: "#78dce8", good: "#a9dc76", - warn: "#ffd866", + warn: "#fc9867", bad: "#ff6188", bright: "#ab9df2", rule: "#403e41", @@ -515,7 +515,7 @@ export const ICON = { pending: "·", pointer: "→", dot: "·", - warn: "⚠", + warn: "!", bar: "▌", down: "↓", up: "↑", diff --git a/src/update/manifest.test.ts b/src/update/manifest.test.ts index e8487d0..2744232 100644 --- a/src/update/manifest.test.ts +++ b/src/update/manifest.test.ts @@ -8,7 +8,7 @@ import { readManifest } from "./manifest"; describe("readManifest", () => { let dir: string; beforeEach(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), "torlink-manifest-")); + dir = fs.mkdtempSync(path.join(os.tmpdir(), "torhunt-manifest-")); }); afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/update/run.test.ts b/src/update/run.test.ts index e35f6fc..0e52567 100644 --- a/src/update/run.test.ts +++ b/src/update/run.test.ts @@ -3,18 +3,18 @@ import { managedInstallOwner } from "./run"; describe("managedInstallOwner", () => { it("names nix for a store path, whatever the separators", () => { - expect(managedInstallOwner("/nix/store/abc123-torlnk-1.4.2")).toBe("nix"); - expect(managedInstallOwner("\\nix\\store\\abc123-torlnk-1.4.2")).toBe("nix"); + expect(managedInstallOwner("/nix/store/abc123-torhunt-1.4.2")).toBe("nix"); + expect(managedInstallOwner("\\nix\\store\\abc123-torhunt-1.4.2")).toBe("nix"); }); it("names the package manager when the root is not writable", () => { const denied = (): void => { throw new Error("EACCES"); }; - expect(managedInstallOwner("/usr/lib/node_modules/torlnk", denied)).toBe("your package manager"); + expect(managedInstallOwner("/usr/lib/node_modules/torhunt", denied)).toBe("your package manager"); }); it("returns null for a writable root we own", () => { - expect(managedInstallOwner("/home/u/dev/torlink", () => {})).toBeNull(); + expect(managedInstallOwner("/home/u/dev/torhunt", () => {})).toBeNull(); }); }); diff --git a/src/update/run.ts b/src/update/run.ts index 8af7db7..ab5f605 100644 --- a/src/update/run.ts +++ b/src/update/run.ts @@ -1,4 +1,4 @@ -// `torlnk update`: fetch the latest release, apply it, and bring any --daemon +// `torhunt update`: fetch the latest release, apply it, and bring any --daemon // process back on the new code. Two install shapes are handled: a git checkout // (pull, install, build) and a global npm install (npm i -g), chosen by whether // the package root is a git working tree. The package name and root come from diff --git a/src/update/version.ts b/src/update/version.ts index 918a528..84e6ca3 100644 --- a/src/update/version.ts +++ b/src/update/version.ts @@ -2,7 +2,7 @@ import { fetchResilient, USER_AGENT, type FetchImpl } from "../util/net"; import { readManifest } from "./manifest"; // Compare two dotted versions numerically. Pre-release / build suffixes (-rc.1, -// +build) are dropped before comparing; torlink ships plain x.y.z releases, and +// +build) are dropped before comparing; torhunt ships plain x.y.z releases, and // a half-parsed suffix is worse than ignoring it. Returns <0, 0, >0 like a // sort comparator (a older, equal, a newer). export function compareVersions(a: string, b: string): number { @@ -30,7 +30,7 @@ export function isNewer(current: string, candidate: string): boolean { // Ask the npm registry for the published version of whatever package this code // ships in: the name comes from the manifest, never a hardcoded slug, so the // comparison always runs against the real npm package. Works no matter how -// torlink was installed (npm, nix, a git checkout), since they all track the +// torhunt was installed (npm, nix, a git checkout), since they all track the // same release. Never throws: the caller is either a background banner or a // one-shot command, and neither should care that the network was down. export async function fetchLatestVersion(opts: { diff --git a/src/util/crashlog.test.ts b/src/util/crashlog.test.ts index 001a4c4..5c7853c 100644 --- a/src/util/crashlog.test.ts +++ b/src/util/crashlog.test.ts @@ -4,11 +4,11 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; // Same isolation story as bootguard.test.ts: the log path is derived from -// TORLINK_STATE_DIR at module init, so each test gets a private dir and fresh +// TORHUNT_STATE_DIR at module init, so each test gets a private dir and fresh // module instances. async function isolated() { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-crashlog-")); - vi.stubEnv("TORLINK_STATE_DIR", dir); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "torhunt-crashlog-")); + vi.stubEnv("TORHUNT_STATE_DIR", dir); vi.resetModules(); const crashlog = await import("./crashlog"); return { dir, crashlog }; diff --git a/src/util/crashlog.ts b/src/util/crashlog.ts index 281e2b9..f07d627 100644 --- a/src/util/crashlog.ts +++ b/src/util/crashlog.ts @@ -31,7 +31,7 @@ export function containUnhandledRejections(opts: { echo?: boolean } = {}): void logCrash("unhandledRejection", reason); if (opts.echo) { const msg = reason instanceof Error ? reason.message : String(reason); - console.error(`[torlnk] recovered from a background error: ${msg}`); + console.error(`[torhunt] recovered from a background error: ${msg}`); } }); } diff --git a/src/util/net.ts b/src/util/net.ts index d60b5a0..54ec7b8 100644 --- a/src/util/net.ts +++ b/src/util/net.ts @@ -1,4 +1,4 @@ -export const USER_AGENT = "torlink (+https://www.npmjs.com/package/torlnk)"; +export const USER_AGENT = "torhunt (+https://www.npmjs.com/package/torhunt)"; export type FetchImpl = (url: string, init?: RequestInit) => Promise; export type SleepImpl = (ms: number, signal?: AbortSignal) => Promise; diff --git a/src/util/notify.ts b/src/util/notify.ts index 4a04f66..e7a2d55 100644 --- a/src/util/notify.ts +++ b/src/util/notify.ts @@ -14,26 +14,38 @@ export function sendNotification(title: string, message: string): void { try { if (platform === "win32") { - // Windows 10/11 native WinRT Toast Notification + // Windows 10/11 native WinRT Toast Notification with registered PowerShell AppID, sound cue & NotifyIcon fallback + const xmlPayload = `${safeTitle}${safeMessage}`; + const psXmlString = "'" + xmlPayload.replace(/'/g, "''") + "'"; + const appId = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe"; const script = ` -[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null -[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null -$template = @" - - - - ${safeTitle} - ${safeMessage} - - - -"@ -$xml = New-Object Windows.Data.Xml.Dom.XmlDocument -$xml.LoadXml($template) -$toast = New-Object Windows.UI.Notifications.ToastNotification $xml -$appId = 'Windows.SystemToast.Notification' -$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId) -$notifier.Show($toast) +$success = $false +try { + [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null + [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null + $xml = New-Object Windows.Data.Xml.Dom.XmlDocument + $xml.LoadXml(${psXmlString}) + $toast = New-Object Windows.UI.Notifications.ToastNotification $xml + $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${appId}') + $notifier.Show($toast) + $success = $true +} catch {} + +if (-not $success) { + try { + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + $notify = New-Object System.Windows.Forms.NotifyIcon + $notify.Icon = [System.Drawing.SystemIcons]::Information + $notify.BalloonTipIcon = [System.Windows.Forms.ToolTipIcon]::Info + $notify.BalloonTipTitle = '${safeTitle}' + $notify.BalloonTipText = '${safeMessage}' + $notify.Visible = $true + $notify.ShowBalloonTip(5000) + Start-Sleep -s 5 + $notify.Dispose() + } catch {} +} `; const encoded = Buffer.from(script, "utf16le").toString("base64"); const child = spawn("powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], { diff --git a/src/util/openFolder.test.ts b/src/util/openFolder.test.ts index 444f44d..0f0955d 100644 --- a/src/util/openFolder.test.ts +++ b/src/util/openFolder.test.ts @@ -36,9 +36,9 @@ describe("openFolder", () => { const { openFolder } = await import("./openFolder"); - await expect(openFolder("/home/me/Downloads/torlink")).resolves.toBe(true); - expect(spawn).toHaveBeenCalledWith("xdg-open", ["/home/me/Downloads/torlink"]); - expect(spawn).toHaveBeenCalledWith("gio", ["open", "/home/me/Downloads/torlink"]); + await expect(openFolder("/home/me/Downloads/torhunt")).resolves.toBe(true); + expect(spawn).toHaveBeenCalledWith("xdg-open", ["/home/me/Downloads/torhunt"]); + expect(spawn).toHaveBeenCalledWith("gio", ["open", "/home/me/Downloads/torhunt"]); } finally { restore(); } @@ -52,8 +52,8 @@ describe("openFolder", () => { const { openFolder } = await import("./openFolder"); - await expect(openFolder("C:\\Users\\me\\Downloads\\torlink")).resolves.toBe(true); - expect(spawn).toHaveBeenCalledWith("explorer", ["C:\\Users\\me\\Downloads\\torlink"]); + await expect(openFolder("C:\\Users\\me\\Downloads\\torhunt")).resolves.toBe(true); + expect(spawn).toHaveBeenCalledWith("explorer", ["C:\\Users\\me\\Downloads\\torhunt"]); } finally { restore(); } diff --git a/src/util/qrcode.test.ts b/src/util/qrcode.test.ts new file mode 100644 index 0000000..35975d8 --- /dev/null +++ b/src/util/qrcode.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { compactMagnet, encodeQrMatrix, renderQrToTerminal } from "./qrcode"; + +describe("qrcode generator", () => { + it("compacts magnet links to minimal uppercase btih urn", () => { + const longMagnet = + "magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709&dn=Ubuntu&tr=udp://tracker.opentrackr.org:1337/announce"; + const compacted = compactMagnet(longMagnet); + expect(compacted).toBe("MAGNET:?XT=URN:BTIH:3I42H3S6NNFQ2MSVX7XZKYAYSCX5QBYJ"); + }); + + it("encodes a small string to a valid square matrix", () => { + const matrix = encodeQrMatrix("hello world"); + expect(matrix.length).toBeGreaterThan(20); + expect(matrix[0]?.length).toBe(matrix.length); + }); + + it("uses alphanumeric mode for uppercase input producing a valid compact matrix", () => { + const magnet = compactMagnet( + "magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709", + ); + const matrix = encodeQrMatrix(magnet); + // 52 uppercase chars fit in Version 3 (29x29) + expect(matrix.length).toBe(29); + expect(matrix[0]?.length).toBe(29); + + // Short alphanumeric string fits in Version 1 (21x21) + const shortMatrix = encodeQrMatrix("TEST1234"); + expect(shortMatrix.length).toBe(21); + }); + + it("encodes a full magnet URI into a scannable matrix and terminal half-block lines", () => { + const magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Ubuntu+24.04"; + const matrix = encodeQrMatrix(magnet); + expect(matrix.length).toBeGreaterThan(20); + + const lines = renderQrToTerminal(matrix); + expect(lines.length).toBeGreaterThan(10); + expect(lines[0]?.length).toBeGreaterThan(20); + expect(lines.some((l) => l.includes("█") || l.includes("▀") || l.includes("▄"))).toBe(true); + }); +}); diff --git a/src/util/qrcode.ts b/src/util/qrcode.ts new file mode 100644 index 0000000..d5c98fa --- /dev/null +++ b/src/util/qrcode.ts @@ -0,0 +1,422 @@ +/** + * Zero-dependency QR Code (Model 2, Byte Mode) Matrix Generator & Terminal Renderer. + * Uses unicode half-block characters (▀, ▄, █, ' ') where each character cell + * represents 1 module horizontally and 2 modules vertically, matching terminal + * 1:2 character aspect ratio for a true 1:1 square QR matrix. + */ + +const EXP_TABLE: number[] = new Array(512).fill(0); +const LOG_TABLE: number[] = new Array(256).fill(0); + +(() => { + let val = 1; + for (let i = 0; i < 255; i++) { + EXP_TABLE[i] = val; + EXP_TABLE[i + 255] = val; + LOG_TABLE[val] = i; + val <<= 1; + if (val & 256) val ^= 285; + } +})(); + +function gfMul(x: number, y: number): number { + if (x === 0 || y === 0) return 0; + return EXP_TABLE[LOG_TABLE[x]! + LOG_TABLE[y]!]!; +} + +function gfPolyMul(p1: number[], p2: number[]): number[] { + const result: number[] = new Array(p1.length + p2.length - 1).fill(0); + for (let i = 0; i < p1.length; i++) { + for (let j = 0; j < p2.length; j++) { + result[i + j]! ^= gfMul(p1[i]!, p2[j]!); + } + } + return result; +} + +function getGeneratorPoly(degree: number): number[] { + let poly: number[] = [1]; + for (let i = 0; i < degree; i++) { + poly = gfPolyMul(poly, [1, EXP_TABLE[i]!]); + } + return poly; +} + +function calculateEcc(data: number[], eccCount: number): number[] { + const gen = getGeneratorPoly(eccCount); + const remainder: number[] = new Array(eccCount).fill(0); + for (let i = 0; i < data.length; i++) { + const factor = data[i]! ^ remainder[0]!; + for (let j = 0; j < eccCount - 1; j++) { + remainder[j] = remainder[j + 1]! ^ gfMul(gen[j + 1]!, factor); + } + remainder[eccCount - 1] = gfMul(gen[eccCount]!, factor); + } + return remainder; +} + +// Version table: [version, totalBytes, eccBytesPerBlock, numBlocksGroup1, dataBytesPerBlockG1, numBlocksGroup2, dataBytesPerBlockG2] +const VERSION_TABLE_L: [number, number, number, number, number, number, number][] = [ + [1, 26, 7, 1, 19, 0, 0], + [2, 44, 10, 1, 34, 0, 0], + [3, 70, 15, 1, 55, 0, 0], + [4, 100, 20, 1, 80, 0, 0], + [5, 134, 26, 1, 108, 0, 0], + [6, 172, 18, 2, 68, 0, 0], + [7, 196, 20, 2, 78, 0, 0], + [8, 242, 24, 2, 97, 0, 0], + [9, 292, 30, 2, 116, 0, 0], + [10, 346, 18, 2, 68, 2, 69], + [11, 404, 20, 4, 81, 0, 0], + [12, 466, 24, 2, 92, 2, 93], + [13, 532, 26, 4, 107, 0, 0], + [14, 581, 30, 3, 115, 1, 116], +]; + +const ALIGNMENT_PATTERN_POSITIONS = [ + [], // V1 + [6, 18], // V2 + [6, 22], // V3 + [6, 26], // V4 + [6, 30], // V5 + [6, 34], // V6 + [6, 22, 38], // V7 + [6, 24, 42], // V8 + [6, 26, 46], // V9 + [6, 28, 50], // V10 + [6, 30, 54], // V11 + [6, 32, 58], // V12 + [6, 34, 62], // V13 + [6, 26, 46, 66], // V14 +]; + +function hexToBase32(hex: string): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let bits = 0; + let value = 0; + let output = ""; + for (let i = 0; i < hex.length; i += 2) { + value = (value << 8) | parseInt(hex.substring(i, i + 2), 16); + bits += 8; + while (bits >= 5) { + output += alphabet[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) { + output += alphabet[(value << (5 - bits)) & 31]; + } + return output; +} + +/** + * Strips trackers and formats the magnet URI using standard 32-char Base32 BTIH. + * 52 characters total fits cleanly into a Version 3 QR code (29x29 modules). + */ +export function compactMagnet(magnet: string): string { + const hashMatch = magnet.match(/urn:btih:([a-fA-F0-9]{40}|[a-zA-Z2-7]{32})/i); + if (!hashMatch) return magnet.trim(); + const rawHash = hashMatch[1]!; + const b32 = rawHash.length === 40 ? hexToBase32(rawHash) : rawHash.toUpperCase(); + // Fully uppercase so the QR encoder can use alphanumeric mode (5.5 bits/char + // instead of 8), which drops the matrix from Version 3 (29×29) to Version 2 + // (25×25). Magnet URI scheme/params and Base32 hashes are case-insensitive. + return `MAGNET:?XT=URN:BTIH:${b32}`; +} + +export function encodeQrMatrix(text: string): boolean[][] { + // QR Alphanumeric charset: 0-9, A-Z, SP, $, %, *, +, -, ., /, :, ? + const ALNUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:?"; + const alnumIndices: number[] = []; + let isAlphanumeric = true; + for (let i = 0; i < text.length; i++) { + const idx = ALNUM.indexOf(text[i]!); + if (idx === -1) { + isAlphanumeric = false; + break; + } + alnumIndices.push(idx); + } + + const utf8 = Buffer.from(text, "utf-8"); + const dataLen = isAlphanumeric ? text.length : utf8.length; + + // Compute data bits needed for the chosen mode + const dataBitsForVersion = (version: number): number => { + if (isAlphanumeric) { + const ccBits = version < 10 ? 9 : 13; + const pairs = Math.floor(dataLen / 2); + const odd = dataLen % 2; + return 4 + ccBits + pairs * 11 + odd * 6; + } + const ccBits = version < 10 ? 8 : 16; + return 4 + ccBits + dataLen * 8; + }; + + let chosenVer = -1; + let verConfig: [number, number, number, number, number, number, number] | null = null; + for (const row of VERSION_TABLE_L) { + const version = row[0]; + const capacityBytes = row[3] * row[4] + row[5] * row[6]; + const totalDataBits = dataBitsForVersion(version); + if (Math.ceil(totalDataBits / 8) <= capacityBytes) { + chosenVer = version; + verConfig = row; + break; + } + } + + if (chosenVer === -1 || !verConfig) { + chosenVer = 14; + verConfig = VERSION_TABLE_L[VERSION_TABLE_L.length - 1]!; + } + + const [version, totalCodewords, eccPerBlock, b1, d1, b2, d2] = verConfig; + const dataCapacity = b1 * d1 + b2 * d2; + + const bits: number[] = []; + const appendBits = (val: number, len: number): void => { + for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1); + }; + + if (isAlphanumeric) { + // Alphanumeric mode: indicator 0010, pairs encoded in 11 bits, odd in 6 + appendBits(0b0010, 4); + const charCountBits = version < 10 ? 9 : 13; + appendBits(dataLen, charCountBits); + for (let i = 0; i < dataLen - 1; i += 2) { + appendBits(alnumIndices[i]! * 45 + alnumIndices[i + 1]!, 11); + } + if (dataLen % 2 === 1) { + appendBits(alnumIndices[dataLen - 1]!, 6); + } + } else { + // Byte mode: indicator 0100, each byte in 8 bits + appendBits(0b0100, 4); + const charCountBits = version < 10 ? 8 : 16; + appendBits(utf8.length, charCountBits); + for (let i = 0; i < utf8.length; i++) { + appendBits(utf8[i]!, 8); + } + } + + const maxDataBits = dataCapacity * 8; + const termLen = Math.min(4, maxDataBits - bits.length); + appendBits(0, termLen); + + while (bits.length % 8 !== 0) bits.push(0); + + const padBytes = [0xec, 0x11]; + let padIdx = 0; + while (bits.length < maxDataBits) { + appendBits(padBytes[padIdx % 2]!, 8); + padIdx++; + } + + const dataBytes: number[] = new Array(dataCapacity).fill(0); + for (let i = 0; i < dataCapacity; i++) { + let byteVal = 0; + for (let b = 0; b < 8; b++) { + byteVal = (byteVal << 1) | bits[i * 8 + b]!; + } + dataBytes[i] = byteVal; + } + + const dataBlocks: number[][] = []; + const eccBlocks: number[][] = []; + let byteOffset = 0; + + for (let i = 0; i < b1; i++) { + const block = dataBytes.slice(byteOffset, byteOffset + d1); + dataBlocks.push(block); + eccBlocks.push(calculateEcc(block, eccPerBlock)); + byteOffset += d1; + } + for (let i = 0; i < b2; i++) { + const block = dataBytes.slice(byteOffset, byteOffset + d2); + dataBlocks.push(block); + eccBlocks.push(calculateEcc(block, eccPerBlock)); + byteOffset += d2; + } + + const finalCodewords: number[] = new Array(totalCodewords).fill(0); + let finalIdx = 0; + const maxDataLen = Math.max(d1, d2); + for (let i = 0; i < maxDataLen; i++) { + for (const b of dataBlocks) { + if (i < b.length) finalCodewords[finalIdx++] = b[i]!; + } + } + for (let i = 0; i < eccPerBlock; i++) { + for (const b of eccBlocks) { + finalCodewords[finalIdx++] = b[i]!; + } + } + + const size = 17 + version * 4; + const matrix: (boolean | null)[][] = Array.from({ length: size }, () => + Array.from({ length: size }, () => null), + ); + const isFunction: boolean[][] = Array.from({ length: size }, () => + Array.from({ length: size }, () => false), + ); + + const setModule = (r: number, c: number, val: boolean, isFunc = true): void => { + if (r >= 0 && r < size && c >= 0 && c < size) { + matrix[r]![c] = val; + if (isFunc) isFunction[r]![c] = true; + } + }; + + const drawFinder = (r: number, c: number): void => { + for (let dr = -1; dr <= 7; dr++) { + for (let dc = -1; dc <= 7; dc++) { + const row = r + dr; + const col = c + dc; + if (row >= 0 && row < size && col >= 0 && col < size) { + if (dr === -1 || dr === 7 || dc === -1 || dc === 7) { + setModule(row, col, false); + } else if (dr === 0 || dr === 6 || dc === 0 || dc === 6) { + setModule(row, col, true); + } else if (dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4) { + setModule(row, col, true); + } else { + setModule(row, col, false); + } + } + } + } + }; + + drawFinder(0, 0); + drawFinder(0, size - 7); + drawFinder(size - 7, 0); + + if (version >= 2) { + const coords = ALIGNMENT_PATTERN_POSITIONS[version - 1] ?? []; + for (const r of coords) { + for (const c of coords) { + if (isFunction[r]![c]) continue; + for (let dr = -2; dr <= 2; dr++) { + for (let dc = -2; dc <= 2; dc++) { + const row = r + dr; + const col = c + dc; + if (dr === -2 || dr === 2 || dc === -2 || dc === 2 || (dr === 0 && dc === 0)) { + setModule(row, col, true); + } else { + setModule(row, col, false); + } + } + } + } + } + } + + for (let i = 8; i < size - 8; i++) { + if (matrix[6]![i] === null) setModule(6, i, i % 2 === 0); + if (matrix[i]![6] === null) setModule(i, 6, i % 2 === 0); + } + + setModule(4 * version + 9, 8, true); + + for (let i = 0; i <= 8; i++) { + if (i !== 6) { + isFunction[8]![i] = true; + isFunction[i]![8] = true; + } + } + for (let i = 0; i < 8; i++) { + isFunction[8]![size - 1 - i] = true; + isFunction[size - 1 - i]![8] = true; + } + + let bitIndex = 0; + const totalBits = finalCodewords.length * 8; + let right = size - 1; + let upwards = true; + + while (right > 0) { + if (right === 6) right--; + const colList = [right, right - 1]; + const rowRange = upwards + ? Array.from({ length: size }, (_, i) => size - 1 - i) + : Array.from({ length: size }, (_, i) => i); + + for (const row of rowRange) { + for (const col of colList) { + if (!isFunction[row]![col]) { + let bit = false; + if (bitIndex < totalBits) { + const bytePos = bitIndex >>> 3; + const bitOffset = 7 - (bitIndex & 7); + bit = ((finalCodewords[bytePos]! >>> bitOffset) & 1) === 1; + bitIndex++; + } + const mask = (row + col) % 2 === 0; + matrix[row]![col] = bit !== mask; + } + } + } + right -= 2; + upwards = !upwards; + } + + const FORMAT_BITS = [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0]; + + for (let i = 0; i < 6; i++) matrix[8]![i] = FORMAT_BITS[i] === 1; + matrix[8]![7] = FORMAT_BITS[6] === 1; + matrix[8]![8] = FORMAT_BITS[7] === 1; + matrix[7]![8] = FORMAT_BITS[8] === 1; + for (let i = 9; i < 15; i++) matrix[14 - i]![8] = FORMAT_BITS[i] === 1; + + for (let i = 0; i < 8; i++) matrix[size - 1 - i]![8] = FORMAT_BITS[i] === 1; + for (let i = 8; i < 15; i++) matrix[8]![size - 15 + i] = FORMAT_BITS[i] === 1; + + return matrix.map((row) => row.map((c) => c ?? false)); +} + +/** + * Render QR matrix to terminal lines using unicode half-block characters (▀, ▄, █, ' '). + * 1 horizontal module = 1 monospace char width (8px). + * 2 vertical modules = 1 monospace line height (16px / 2 = 8px). + * This produces an exact 1:1 square geometry in standard terminal fonts. + */ +export function renderQrToTerminal(matrix: boolean[][]): string[] { + const quietZone = 0; + const size = matrix.length; + const paddedSize = size + quietZone * 2; + + const padded: boolean[][] = Array.from({ length: paddedSize }, (_, r) => + Array.from({ length: paddedSize }, (_, c) => { + const mr = r - quietZone; + const mc = c - quietZone; + return mr >= 0 && mr < size && mc >= 0 && mc < size ? (matrix[mr]?.[mc] ?? false) : false; + }), + ); + + const lines: string[] = []; + for (let r = 0; r < paddedSize; r += 2) { + let rowStr = ""; + const topRow = padded[r]!; + const botRow = r + 1 < paddedSize ? padded[r + 1]! : null; + + for (let c = 0; c < paddedSize; c++) { + const top = topRow[c]; + const bot = botRow ? botRow[c] : false; + + if (top && bot) { + rowStr += "█"; + } else if (top && !bot) { + rowStr += "▀"; + } else if (!top && bot) { + rowStr += "▄"; + } else { + rowStr += " "; + } + } + lines.push(rowStr); + } + + return lines; +} + diff --git a/vitest.config.ts b/vitest.config.ts index f4eb86d..43a8f63 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,13 +3,13 @@ import path from "node:path"; import { defineConfig } from "vitest/config"; // Keep tests off the real user data dir: redirect all persisted state (queue / -// history / seeds / config) into a temp folder via the TORLINK_STATE_DIR +// history / seeds / config) into a temp folder via the TORHUNT_STATE_DIR // override that src/config/paths.ts honors. Applied before test modules import // paths.ts, so every write during a run lands here instead. export default defineConfig({ test: { env: { - TORLINK_STATE_DIR: path.join(os.tmpdir(), "torlink-test-state"), + TORHUNT_STATE_DIR: path.join(os.tmpdir(), "torhunt-test-state"), }, }, });