mirror of
https://github.com/baairon/torlink.git
synced 2026-07-08 18:28:22 +02:00
feat: let users add their own trackers via the t key (#31)
This commit is contained in:
+13
-6
@@ -4,10 +4,12 @@ import { serializeWrites, writeJsonAtomic } from "../util/atomic";
|
|||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
downloadDir: string;
|
downloadDir: string;
|
||||||
|
trackers: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultConfig: Config = {
|
export const defaultConfig: Config = {
|
||||||
downloadDir: defaultDownloadDir,
|
downloadDir: defaultDownloadDir,
|
||||||
|
trackers: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadConfig(): Promise<Config> {
|
export async function loadConfig(): Promise<Config> {
|
||||||
@@ -15,17 +17,22 @@ export async function loadConfig(): Promise<Config> {
|
|||||||
try {
|
try {
|
||||||
raw = await fs.readFile(configFile, "utf8");
|
raw = await fs.readFile(configFile, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
return { ...defaultConfig };
|
return { ...defaultConfig, trackers: [] };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as Partial<Config>;
|
const parsed = JSON.parse(raw) as Partial<Config>;
|
||||||
const cfg = { ...defaultConfig, ...parsed };
|
const cfg: Config = {
|
||||||
if (!cfg.downloadDir || typeof cfg.downloadDir !== "string") {
|
downloadDir:
|
||||||
cfg.downloadDir = defaultDownloadDir;
|
typeof parsed.downloadDir === "string" && parsed.downloadDir
|
||||||
}
|
? parsed.downloadDir
|
||||||
|
: defaultDownloadDir,
|
||||||
|
trackers: Array.isArray(parsed.trackers)
|
||||||
|
? parsed.trackers.filter((t): t is string => typeof t === "string" && t.length > 0)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
return cfg;
|
return cfg;
|
||||||
} catch {
|
} catch {
|
||||||
return { ...defaultConfig };
|
return { ...defaultConfig, trackers: [] };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatTrackers, parseTrackers } from "./trackers";
|
||||||
|
|
||||||
|
describe("parseTrackers", () => {
|
||||||
|
it("returns empty for blank input", () => {
|
||||||
|
expect(parseTrackers("")).toEqual([]);
|
||||||
|
expect(parseTrackers(" \n\t ")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits on commas, whitespace, and newlines", () => {
|
||||||
|
const input =
|
||||||
|
"udp://a.example:1337/announce, http://b.example/announce\nhttps://c.example/announce\tudp://d.example:80";
|
||||||
|
expect(parseTrackers(input)).toEqual([
|
||||||
|
"udp://a.example:1337/announce",
|
||||||
|
"http://b.example/announce",
|
||||||
|
"https://c.example/announce",
|
||||||
|
"udp://d.example:80",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes exact duplicates", () => {
|
||||||
|
const input = "udp://a.example:1337, udp://a.example:1337 udp://a.example:1337";
|
||||||
|
expect(parseTrackers(input)).toEqual(["udp://a.example:1337"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only udp, http(s), ws(s) schemes", () => {
|
||||||
|
const input =
|
||||||
|
"udp://a.example:80 http://b.example ftp://c.example file:///etc/passwd hello ws://d.example wss://e.example";
|
||||||
|
expect(parseTrackers(input)).toEqual([
|
||||||
|
"udp://a.example:80",
|
||||||
|
"http://b.example",
|
||||||
|
"ws://d.example",
|
||||||
|
"wss://e.example",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves order", () => {
|
||||||
|
const input = "https://z.example, https://a.example, https://m.example";
|
||||||
|
expect(parseTrackers(input)).toEqual([
|
||||||
|
"https://z.example",
|
||||||
|
"https://a.example",
|
||||||
|
"https://m.example",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatTrackers", () => {
|
||||||
|
it("joins with a comma and space", () => {
|
||||||
|
expect(formatTrackers(["a", "b", "c"])).toBe("a, b, c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty array", () => {
|
||||||
|
expect(formatTrackers([])).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const VALID_SCHEME = /^(udp|https?|wss?):\/\//i;
|
||||||
|
|
||||||
|
export function parseTrackers(input: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const raw of input.split(/[\s,]+/)) {
|
||||||
|
const url = raw.trim();
|
||||||
|
if (!url || !VALID_SCHEME.test(url)) continue;
|
||||||
|
if (seen.has(url)) continue;
|
||||||
|
seen.add(url);
|
||||||
|
out.push(url);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTrackers(trackers: string[]): string {
|
||||||
|
return trackers.join(", ");
|
||||||
|
}
|
||||||
+11
-2
@@ -47,7 +47,15 @@ export class TorrentEngine {
|
|||||||
// `source` is a magnet URI, an infoHash, or a path to a .torrent file. Seeding
|
// `source` is a magnet URI, an infoHash, or a path to a .torrent file. Seeding
|
||||||
// an existing file passes the stored .torrent path so webtorrent can verify it
|
// an existing file passes the stored .torrent path so webtorrent can verify it
|
||||||
// locally instead of re-fetching metadata from the swarm.
|
// locally instead of re-fetching metadata from the swarm.
|
||||||
add(id: string, source: string, dir: string, handlers: AddHandlers): void {
|
// `announce` supplements whatever trackers are already in the source URI;
|
||||||
|
// webtorrent dedupes internally.
|
||||||
|
add(
|
||||||
|
id: string,
|
||||||
|
source: string,
|
||||||
|
dir: string,
|
||||||
|
handlers: AddHandlers,
|
||||||
|
announce?: string[],
|
||||||
|
): void {
|
||||||
const client = this.ensureClient();
|
const client = this.ensureClient();
|
||||||
const existing = this.torrents.get(id);
|
const existing = this.torrents.get(id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -57,9 +65,10 @@ export class TorrentEngine {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const opts = announce && announce.length > 0 ? { path: dir, announce } : { path: dir };
|
||||||
let torrent: Torrent;
|
let torrent: Torrent;
|
||||||
try {
|
try {
|
||||||
torrent = client.add(source, { path: dir });
|
torrent = client.add(source, opts);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handlers.onError?.(message(e));
|
handlers.onError?.(message(e));
|
||||||
return;
|
return;
|
||||||
|
|||||||
+10
-2
@@ -53,6 +53,14 @@ export class DownloadQueue extends EventEmitter {
|
|||||||
private seeds = new Map<string, SeedItem>();
|
private seeds = new Map<string, SeedItem>();
|
||||||
private strayHits = new Map<string, number>();
|
private strayHits = new Map<string, number>();
|
||||||
private seedStartedAt = new Map<string, number>();
|
private seedStartedAt = new Map<string, number>();
|
||||||
|
private trackers: string[] = [];
|
||||||
|
|
||||||
|
// Extra announce URLs appended to every torrent added from now on.
|
||||||
|
// Existing running torrents aren't retro-updated — the change takes effect
|
||||||
|
// for the next add / resume / re-seed.
|
||||||
|
setTrackers(trackers: string[]): void {
|
||||||
|
this.trackers = trackers;
|
||||||
|
}
|
||||||
|
|
||||||
getItems(): QueueItem[] {
|
getItems(): QueueItem[] {
|
||||||
return [...this.items.values()].sort((a, b) => b.addedAt - a.addedAt);
|
return [...this.items.values()].sort((a, b) => b.addedAt - a.addedAt);
|
||||||
@@ -102,7 +110,7 @@ export class DownloadQueue extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private startEngine(item: QueueItem): void {
|
private startEngine(item: QueueItem): void {
|
||||||
this.engine.add(item.id, item.magnet, item.dir, this.engineHandlers(item.id));
|
this.engine.add(item.id, item.magnet, item.dir, this.engineHandlers(item.id), this.trackers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// One torrent serves an item across its whole life (download -> seed ->
|
// One torrent serves an item across its whole life (download -> seed ->
|
||||||
@@ -372,7 +380,7 @@ export class DownloadQueue extends EventEmitter {
|
|||||||
// Seed from the stored .torrent metadata when we have it (verifies the local
|
// Seed from the stored .torrent metadata when we have it (verifies the local
|
||||||
// file immediately, no swarm needed); fall back to the magnet otherwise.
|
// file immediately, no swarm needed); fall back to the magnet otherwise.
|
||||||
const source = torrentMetaExists(h.id) ? torrentMetaPath(h.id) : h.magnet;
|
const source = torrentMetaExists(h.id) ? torrentMetaPath(h.id) : h.magnet;
|
||||||
this.engine.add(h.id, source, h.dir, this.engineHandlers(h.id));
|
this.engine.add(h.id, source, h.dir, this.engineHandlers(h.id), this.trackers);
|
||||||
this.ensurePoll();
|
this.ensurePoll();
|
||||||
this.changed();
|
this.changed();
|
||||||
void this.persistSeeds();
|
void this.persistSeeds();
|
||||||
|
|||||||
+53
-8
@@ -33,6 +33,7 @@ import { Spinner } from "./components/Spinner";
|
|||||||
import { TabTitle } from "./components/TabTitle";
|
import { TabTitle } from "./components/TabTitle";
|
||||||
import { Splash } from "./views/Splash";
|
import { Splash } from "./views/Splash";
|
||||||
import { FolderPrompt } from "./components/FolderPrompt";
|
import { FolderPrompt } from "./components/FolderPrompt";
|
||||||
|
import { TrackersPrompt } from "./components/TrackersPrompt";
|
||||||
import { footerHints } from "./keymap";
|
import { footerHints } from "./keymap";
|
||||||
import { COLOR, ICON } from "./theme";
|
import { COLOR, ICON } from "./theme";
|
||||||
import { useMouseWheel } from "./hooks/useMouseWheel";
|
import { useMouseWheel } from "./hooks/useMouseWheel";
|
||||||
@@ -83,6 +84,7 @@ export function App({
|
|||||||
const [seedFocus, setSeedFocus] = useState<SeedFocus | null>(null);
|
const [seedFocus, setSeedFocus] = useState<SeedFocus | null>(null);
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [editingFolder, setEditingFolder] = useState(false);
|
const [editingFolder, setEditingFolder] = useState(false);
|
||||||
|
const [editingTrackers, setEditingTrackers] = useState(false);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const booting = useRef(false);
|
const booting = useRef(false);
|
||||||
|
|
||||||
@@ -93,6 +95,7 @@ export function App({
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const cfg = await loadConfig();
|
const cfg = await loadConfig();
|
||||||
const q = new DownloadQueue();
|
const q = new DownloadQueue();
|
||||||
|
q.setTrackers(cfg.trackers);
|
||||||
q.restore(reconcileQueue(await loadQueue()));
|
q.restore(reconcileQueue(await loadQueue()));
|
||||||
q.restoreHistory(await loadHistory());
|
q.restoreHistory(await loadHistory());
|
||||||
q.restoreSeeds(await loadSeeds());
|
q.restoreSeeds(await loadSeeds());
|
||||||
@@ -148,15 +151,40 @@ export function App({
|
|||||||
else exit();
|
else exit();
|
||||||
}, [queue, onQuit, exit]);
|
}, [queue, onQuit, exit]);
|
||||||
|
|
||||||
const setConfig = useCallback((c: Config) => {
|
const setConfig = useCallback(
|
||||||
setConfigState(c);
|
(c: Config) => {
|
||||||
void saveConfig(c);
|
setConfigState(c);
|
||||||
}, []);
|
queue?.setTrackers(c.trackers);
|
||||||
|
void saveConfig(c);
|
||||||
|
},
|
||||||
|
[queue],
|
||||||
|
);
|
||||||
|
|
||||||
const closeFolderPrompt = useCallback(() => {
|
const closeFolderPrompt = useCallback(() => {
|
||||||
setEditingFolder(false);
|
setEditingFolder(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const closeTrackersPrompt = useCallback(() => {
|
||||||
|
setEditingTrackers(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setTrackers = useCallback(
|
||||||
|
(list: string[]) => {
|
||||||
|
closeTrackersPrompt();
|
||||||
|
if (!config) return;
|
||||||
|
const same =
|
||||||
|
list.length === config.trackers.length &&
|
||||||
|
list.every((t, i) => t === config.trackers[i]);
|
||||||
|
if (same) {
|
||||||
|
setNotice("Trackers unchanged.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfig({ ...config, trackers: list });
|
||||||
|
setNotice(list.length === 0 ? "Cleared extra trackers." : `Saved ${list.length} tracker${list.length === 1 ? "" : "s"}.`);
|
||||||
|
},
|
||||||
|
[config, setConfig, closeTrackersPrompt],
|
||||||
|
);
|
||||||
|
|
||||||
const setDownloadDir = useCallback(
|
const setDownloadDir = useCallback(
|
||||||
(raw: string) => {
|
(raw: string) => {
|
||||||
closeFolderPrompt();
|
closeFolderPrompt();
|
||||||
@@ -278,7 +306,7 @@ export function App({
|
|||||||
submitQuery,
|
submitQuery,
|
||||||
section,
|
section,
|
||||||
setSection,
|
setSection,
|
||||||
region: showHelp || editingFolder ? "help" : region,
|
region: showHelp || editingFolder || editingTrackers ? "help" : region,
|
||||||
setRegion,
|
setRegion,
|
||||||
captureMode,
|
captureMode,
|
||||||
setCaptureMode,
|
setCaptureMode,
|
||||||
@@ -307,6 +335,7 @@ export function App({
|
|||||||
region,
|
region,
|
||||||
showHelp,
|
showHelp,
|
||||||
editingFolder,
|
editingFolder,
|
||||||
|
editingTrackers,
|
||||||
captureMode,
|
captureMode,
|
||||||
downloadFocus,
|
downloadFocus,
|
||||||
seedFocus,
|
seedFocus,
|
||||||
@@ -328,7 +357,7 @@ export function App({
|
|||||||
quitAll();
|
quitAll();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (editingFolder) return; // the folder prompt owns input (its own esc + enter)
|
if (editingFolder || editingTrackers) return; // the prompt owns input (its own esc + enter)
|
||||||
if (captureMode === "text") return;
|
if (captureMode === "text") return;
|
||||||
if (showHelp) {
|
if (showHelp) {
|
||||||
setShowHelp(false);
|
setShowHelp(false);
|
||||||
@@ -343,6 +372,11 @@ export function App({
|
|||||||
setEditingFolder(true);
|
setEditingFolder(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (input === "t") {
|
||||||
|
setShowHelp(false);
|
||||||
|
setEditingTrackers(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (input === "m") {
|
if (input === "m") {
|
||||||
void pasteFromClipboard();
|
void pasteFromClipboard();
|
||||||
return;
|
return;
|
||||||
@@ -420,10 +454,21 @@ export function App({
|
|||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{editingTrackers ? (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<TrackersPrompt
|
||||||
|
width={Math.max(24, Math.min(cols - 4, 78))}
|
||||||
|
value={store.config.trackers}
|
||||||
|
onSubmit={setTrackers}
|
||||||
|
onCancel={closeTrackersPrompt}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
height={bodyH}
|
height={bodyH}
|
||||||
marginTop={compact ? 0 : 1}
|
marginTop={compact ? 0 : 1}
|
||||||
display={showHelp || editingFolder ? "none" : "flex"}
|
display={showHelp || editingFolder || editingTrackers ? "none" : "flex"}
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -439,7 +484,7 @@ export function App({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{showFooter ? (
|
{showFooter ? (
|
||||||
<Box display={showHelp || editingFolder ? "none" : "flex"}>
|
<Box display={showHelp || editingFolder || editingTrackers ? "none" : "flex"}>
|
||||||
<Footer hints={footerHints(region, section, downloadFocus, seedFocus)} />
|
<Footer hints={footerHints(region, section, downloadFocus, seedFocus)} />
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Box, Text, useInput } from "ink";
|
||||||
|
import { TextField } from "./TextField";
|
||||||
|
import { Panel } from "./Panel";
|
||||||
|
import { formatTrackers, parseTrackers } from "../../config/trackers";
|
||||||
|
import { COLOR, ICON } from "../theme";
|
||||||
|
|
||||||
|
interface TrackersPromptProps {
|
||||||
|
width: number;
|
||||||
|
value: string[];
|
||||||
|
onSubmit: (trackers: string[]) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackersPrompt({ width, value, onSubmit, onCancel }: TrackersPromptProps) {
|
||||||
|
useInput((_input, key) => {
|
||||||
|
if (key.escape) onCancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" width={width}>
|
||||||
|
<Panel title="extra trackers" width={width} focused height={2}>
|
||||||
|
<Box>
|
||||||
|
<Text color={COLOR.accent}>{`${ICON.pointer} `}</Text>
|
||||||
|
<Box flexGrow={1} minWidth={0}>
|
||||||
|
<TextField
|
||||||
|
defaultValue={formatTrackers(value)}
|
||||||
|
placeholder="udp://tracker.example:1337/announce, https://..."
|
||||||
|
onSubmit={(raw) => onSubmit(parseTrackers(raw))}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Panel>
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Text color={COLOR.alt}>↵</Text>
|
||||||
|
<Text dimColor> save</Text>
|
||||||
|
<Text dimColor>{` ${ICON.dot} `}</Text>
|
||||||
|
<Text color={COLOR.alt}>esc</Text>
|
||||||
|
<Text dimColor> cancel</Text>
|
||||||
|
</Box>
|
||||||
|
<Text dimColor>
|
||||||
|
Separate with commas or spaces. Empty saves an empty list. Applies to new adds.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export const HELP_GROUPS: HelpGroup[] = [
|
|||||||
{ keys: "tab", label: "Switch pane" },
|
{ keys: "tab", label: "Switch pane" },
|
||||||
{ keys: "esc", label: "Back" },
|
{ keys: "esc", label: "Back" },
|
||||||
{ keys: "o", label: "Download folder" },
|
{ keys: "o", label: "Download folder" },
|
||||||
|
{ keys: "t", label: "Extra trackers" },
|
||||||
{ keys: "q", label: "Quit" },
|
{ keys: "q", label: "Quit" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user