From bf2daceb4932ed599859466e82378d407e24ee77 Mon Sep 17 00:00:00 2001 From: "bairon.dev" Date: Thu, 2 Jul 2026 00:22:12 -0400 Subject: [PATCH] feat: accept a bare infohash (#32) --- README.md | 2 +- src/cli/args.test.ts | 7 ++++++ src/cli/args.ts | 3 +++ src/sources/magnet.test.ts | 49 +++++++++++++++++++++++++++++++++++++- src/sources/magnet.ts | 22 +++++++++++++++++ src/ui/App.tsx | 8 +++---- 6 files changed, 85 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a8e7a38..1e7e5e1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ torlink is a torrent finder that lives in your terminal, with zero setup and not npx torlnk ``` -That's the only thing you'll type. torlink opens straight to a search bar: search for what you want, paste in a magnet link, 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 the only thing you'll type. torlink 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. ## Finding something diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 3766b61..31bbdbe 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -23,7 +23,14 @@ describe("parseCliArgs", () => { initialTorrent: "./Foo.torrent", }); }); + it("launches a bare infohash as a magnet (DHT)", () => { + const hash = "abcdef0123456789abcdef0123456789abcdef01"; + expect(parseCliArgs([hash])).toEqual({ kind: "run", initialMagnet: hash }); + }); it("rejects unknown arguments", () => { expect(parseCliArgs(["--nope"])).toEqual({ kind: "invalid", arg: "--nope" }); }); + it("rejects a non-hash bareword", () => { + expect(parseCliArgs(["hello"])).toEqual({ kind: "invalid", arg: "hello" }); + }); }); diff --git a/src/cli/args.ts b/src/cli/args.ts index a77d0b3..57afeb2 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,3 +1,5 @@ +import { isInfoHash } from "../sources/magnet"; + export type CliCommand = | { kind: "version" } | { kind: "help" } @@ -11,6 +13,7 @@ export function parseCliArgs(argv: string[]): CliCommand { if (a === "--version" || a === "-v") return { kind: "version" }; if (a === "--help" || a === "-h") return { kind: "help" }; if (/^magnet:\?/i.test(a)) return { kind: "run", initialMagnet: a }; + if (isInfoHash(a)) return { kind: "run", initialMagnet: a }; if (/\.torrent$/i.test(a)) return { kind: "run", initialTorrent: a }; return { kind: "invalid", arg: a }; } diff --git a/src/sources/magnet.test.ts b/src/sources/magnet.test.ts index 1d4d8b9..b6b44f2 100644 --- a/src/sources/magnet.test.ts +++ b/src/sources/magnet.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { parseMagnet, normalizeInfoHash, buildMagnet } from "./magnet"; +import { parseMagnet, parseInput, isInfoHash, normalizeInfoHash, buildMagnet } from "./magnet"; describe("parseMagnet", () => { it("keeps a full 40-char hex info hash", () => { @@ -48,3 +48,50 @@ describe("buildMagnet", () => { expect(out).toContain("&tr="); }); }); + +describe("isInfoHash", () => { + it("accepts a bare 40-char hex hash", () => { + expect(isInfoHash("a".repeat(40))).toBe(true); + }); + it("accepts a bare 32-char base32 hash", () => { + expect(isInfoHash("MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U")).toBe(true); + }); + it("rejects ordinary queries and malformed hashes", () => { + expect(isInfoHash("the office 1080p")).toBe(false); + expect(isInfoHash("g".repeat(40))).toBe(false); // 40 chars but not hex + expect(isInfoHash("a".repeat(39))).toBe(false); // too short + expect(isInfoHash("")).toBe(false); + }); +}); + +describe("parseInput", () => { + it("parses a full magnet URI just like parseMagnet", () => { + const hash = "abcdef0123456789abcdef0123456789abcdef01"; + const m = parseInput(`magnet:?xt=urn:btih:${hash}&dn=Cool+Movie`); + expect(m?.infoHash).toBe(hash); + expect(m?.name).toBe("Cool Movie"); + }); + it("wraps a bare 40-char hex hash into a magnet with trackers", () => { + const hash = "abcdef0123456789abcdef0123456789abcdef01"; + const m = parseInput(hash); + expect(m?.infoHash).toBe(hash); + expect(m?.name).toBe(hash); + expect(m?.magnet).toContain(`xt=urn:btih:${hash}`); + expect(m?.magnet).toContain("&tr="); + }); + it("decodes a bare 32-char base32 hash to 40-char hex", () => { + const m = parseInput("MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U"); + expect(m?.infoHash).toMatch(/^[a-f0-9]{40}$/); + expect(m?.magnet).toContain(`xt=urn:btih:${m?.infoHash}`); + }); + it("trims whitespace around a bare hash", () => { + const hash = "abcdef0123456789abcdef0123456789abcdef01"; + expect(parseInput(` ${hash} `)?.infoHash).toBe(hash); + }); + it("returns null for ordinary queries and junk", () => { + expect(parseInput("the office 1080p")).toBeNull(); + expect(parseInput("g".repeat(40))).toBeNull(); // 40 chars but not hex + expect(parseInput("magnet:?xt=urn:btih:tooshort")).toBeNull(); + expect(parseInput("")).toBeNull(); + }); +}); diff --git a/src/sources/magnet.ts b/src/sources/magnet.ts index d216ebc..28541e5 100644 --- a/src/sources/magnet.ts +++ b/src/sources/magnet.ts @@ -59,3 +59,25 @@ export function parseMagnet(input: string): ParsedMagnet | null { } catch {} return { infoHash, name, magnet: s }; } + +// Anchored to the whole input so an ordinary search query is never mistaken for a +// hash: only a string that is *nothing but* a 40-char hex or 32-char base32 info +// hash counts. Same character classes as MAGNET_RE's xt group. +const INFOHASH_RE = /^([a-f0-9]{40}|[a-z2-7]{32})$/i; + +export function isInfoHash(input: string): boolean { + return INFOHASH_RE.test(input.trim()); +} + +// Accepts either a magnet URI or a bare info hash. A bare hash is normalized and +// wrapped with the default public trackers via buildMagnet, so it downloads over +// the DHT (enabled by default in the Node client) plus those trackers, exactly +// like any other magnet. Returns null for anything that is neither. +export function parseInput(input: string): ParsedMagnet | null { + const s = input.trim(); + const magnet = parseMagnet(s); + if (magnet) return magnet; + if (!isInfoHash(s)) return null; + const infoHash = normalizeInfoHash(s); + return { infoHash, name: infoHash, magnet: buildMagnet(infoHash, infoHash) }; +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index eb1e14e..cea4c75 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -7,7 +7,7 @@ import { DownloadQueue } from "../download/queue"; import { loadQueue, loadSeeds } from "../download/persist"; import { loadHistory } from "../download/history"; import { reconcileQueue } from "../download/reconcile"; -import { parseMagnet } from "../sources/magnet"; +import { parseInput } from "../sources/magnet"; import { magnetFromTorrentFile } from "../sources/torrentFile"; import { readClipboard, writeClipboard } from "../util/clipboard"; import { cleanText, truncate } from "../util/format"; @@ -106,7 +106,7 @@ export function App({ setConfigState(cfg); setQueue(q); const launch = initialMagnet - ? parseMagnet(initialMagnet) + ? parseInput(initialMagnet) : initialTorrent ? await magnetFromTorrentFile(initialTorrent) : null; @@ -240,7 +240,7 @@ export function App({ (raw: string) => { const q = raw.trim(); if (q) { - const magnet = parseMagnet(q); + const magnet = parseInput(q); if (magnet) { startDownload({ id: magnet.infoHash, @@ -266,7 +266,7 @@ export function App({ return; } const found = text.match(/magnet:\?xt=urn:btih:[^\s"'<>]+/i)?.[0]; - const magnet = found ? parseMagnet(found) : null; + const magnet = parseInput(found ?? text); if (magnet) { startDownload({ id: magnet.infoHash, name: magnet.name, magnet: magnet.magnet }); setView("browser");