mirror of
https://github.com/baairon/torlink.git
synced 2026-07-08 18:28:22 +02:00
feat: accept a bare infohash (#32)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user