feat: accept a bare infohash (#32)

This commit is contained in:
bairon.dev
2026-07-02 00:22:12 -04:00
parent f3880f15fe
commit bf2daceb49
6 changed files with 85 additions and 6 deletions
+1 -1
View File
@@ -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
+7
View File
@@ -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" });
});
});
+3
View File
@@ -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 };
}
+48 -1
View File
@@ -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();
});
});
+22
View File
@@ -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) };
}
+4 -4
View File
@@ -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");