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 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 ## Finding something
+7
View File
@@ -23,7 +23,14 @@ describe("parseCliArgs", () => {
initialTorrent: "./Foo.torrent", 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", () => { it("rejects unknown arguments", () => {
expect(parseCliArgs(["--nope"])).toEqual({ kind: "invalid", arg: "--nope" }); 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 = export type CliCommand =
| { kind: "version" } | { kind: "version" }
| { kind: "help" } | { kind: "help" }
@@ -11,6 +13,7 @@ export function parseCliArgs(argv: string[]): CliCommand {
if (a === "--version" || a === "-v") return { kind: "version" }; if (a === "--version" || a === "-v") return { kind: "version" };
if (a === "--help" || a === "-h") return { kind: "help" }; if (a === "--help" || a === "-h") return { kind: "help" };
if (/^magnet:\?/i.test(a)) return { kind: "run", initialMagnet: a }; 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 }; if (/\.torrent$/i.test(a)) return { kind: "run", initialTorrent: a };
return { kind: "invalid", arg: a }; return { kind: "invalid", arg: a };
} }
+48 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseMagnet, normalizeInfoHash, buildMagnet } from "./magnet"; import { parseMagnet, parseInput, isInfoHash, normalizeInfoHash, buildMagnet } from "./magnet";
describe("parseMagnet", () => { describe("parseMagnet", () => {
it("keeps a full 40-char hex info hash", () => { it("keeps a full 40-char hex info hash", () => {
@@ -48,3 +48,50 @@ describe("buildMagnet", () => {
expect(out).toContain("&tr="); 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 {} } catch {}
return { infoHash, name, magnet: s }; 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 { loadQueue, loadSeeds } from "../download/persist";
import { loadHistory } from "../download/history"; import { loadHistory } from "../download/history";
import { reconcileQueue } from "../download/reconcile"; import { reconcileQueue } from "../download/reconcile";
import { parseMagnet } from "../sources/magnet"; import { parseInput } from "../sources/magnet";
import { magnetFromTorrentFile } from "../sources/torrentFile"; import { magnetFromTorrentFile } from "../sources/torrentFile";
import { readClipboard, writeClipboard } from "../util/clipboard"; import { readClipboard, writeClipboard } from "../util/clipboard";
import { cleanText, truncate } from "../util/format"; import { cleanText, truncate } from "../util/format";
@@ -106,7 +106,7 @@ export function App({
setConfigState(cfg); setConfigState(cfg);
setQueue(q); setQueue(q);
const launch = initialMagnet const launch = initialMagnet
? parseMagnet(initialMagnet) ? parseInput(initialMagnet)
: initialTorrent : initialTorrent
? await magnetFromTorrentFile(initialTorrent) ? await magnetFromTorrentFile(initialTorrent)
: null; : null;
@@ -240,7 +240,7 @@ export function App({
(raw: string) => { (raw: string) => {
const q = raw.trim(); const q = raw.trim();
if (q) { if (q) {
const magnet = parseMagnet(q); const magnet = parseInput(q);
if (magnet) { if (magnet) {
startDownload({ startDownload({
id: magnet.infoHash, id: magnet.infoHash,
@@ -266,7 +266,7 @@ export function App({
return; return;
} }
const found = text.match(/magnet:\?xt=urn:btih:[^\s"'<>]+/i)?.[0]; const found = text.match(/magnet:\?xt=urn:btih:[^\s"'<>]+/i)?.[0];
const magnet = found ? parseMagnet(found) : null; const magnet = parseInput(found ?? text);
if (magnet) { if (magnet) {
startDownload({ id: magnet.infoHash, name: magnet.name, magnet: magnet.magnet }); startDownload({ id: magnet.infoHash, name: magnet.name, magnet: magnet.magnet });
setView("browser"); setView("browser");