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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user