feat: let users add their own trackers via the t key (#31)

This commit is contained in:
Manish
2026-07-01 14:01:49 -04:00
committed by GitHub
parent 7748c41cf7
commit f3880f15fe
8 changed files with 208 additions and 18 deletions
+13 -6
View File
@@ -4,10 +4,12 @@ import { serializeWrites, writeJsonAtomic } from "../util/atomic";
export interface Config {
downloadDir: string;
trackers: string[];
}
export const defaultConfig: Config = {
downloadDir: defaultDownloadDir,
trackers: [],
};
export async function loadConfig(): Promise<Config> {
@@ -15,17 +17,22 @@ export async function loadConfig(): Promise<Config> {
try {
raw = await fs.readFile(configFile, "utf8");
} catch {
return { ...defaultConfig };
return { ...defaultConfig, trackers: [] };
}
try {
const parsed = JSON.parse(raw) as Partial<Config>;
const cfg = { ...defaultConfig, ...parsed };
if (!cfg.downloadDir || typeof cfg.downloadDir !== "string") {
cfg.downloadDir = defaultDownloadDir;
}
const cfg: Config = {
downloadDir:
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;
} catch {
return { ...defaultConfig };
return { ...defaultConfig, trackers: [] };
}
}
+55
View File
@@ -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("");
});
});
+18
View File
@@ -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
View File
@@ -47,7 +47,15 @@ export class TorrentEngine {
// `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
// 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 existing = this.torrents.get(id);
if (existing) {
@@ -57,9 +65,10 @@ export class TorrentEngine {
} catch {}
}
const opts = announce && announce.length > 0 ? { path: dir, announce } : { path: dir };
let torrent: Torrent;
try {
torrent = client.add(source, { path: dir });
torrent = client.add(source, opts);
} catch (e) {
handlers.onError?.(message(e));
return;
+10 -2
View File
@@ -53,6 +53,14 @@ export class DownloadQueue extends EventEmitter {
private seeds = new Map<string, SeedItem>();
private strayHits = 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[] {
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 {
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 ->
@@ -372,7 +380,7 @@ export class DownloadQueue extends EventEmitter {
// Seed from the stored .torrent metadata when we have it (verifies the local
// file immediately, no swarm needed); fall back to the magnet otherwise.
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.changed();
void this.persistSeeds();
+53 -8
View File
@@ -33,6 +33,7 @@ import { Spinner } from "./components/Spinner";
import { TabTitle } from "./components/TabTitle";
import { Splash } from "./views/Splash";
import { FolderPrompt } from "./components/FolderPrompt";
import { TrackersPrompt } from "./components/TrackersPrompt";
import { footerHints } from "./keymap";
import { COLOR, ICON } from "./theme";
import { useMouseWheel } from "./hooks/useMouseWheel";
@@ -83,6 +84,7 @@ export function App({
const [seedFocus, setSeedFocus] = useState<SeedFocus | null>(null);
const [showHelp, setShowHelp] = useState(false);
const [editingFolder, setEditingFolder] = useState(false);
const [editingTrackers, setEditingTrackers] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const booting = useRef(false);
@@ -93,6 +95,7 @@ export function App({
void (async () => {
const cfg = await loadConfig();
const q = new DownloadQueue();
q.setTrackers(cfg.trackers);
q.restore(reconcileQueue(await loadQueue()));
q.restoreHistory(await loadHistory());
q.restoreSeeds(await loadSeeds());
@@ -148,15 +151,40 @@ export function App({
else exit();
}, [queue, onQuit, exit]);
const setConfig = useCallback((c: Config) => {
setConfigState(c);
void saveConfig(c);
}, []);
const setConfig = useCallback(
(c: Config) => {
setConfigState(c);
queue?.setTrackers(c.trackers);
void saveConfig(c);
},
[queue],
);
const closeFolderPrompt = useCallback(() => {
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(
(raw: string) => {
closeFolderPrompt();
@@ -278,7 +306,7 @@ export function App({
submitQuery,
section,
setSection,
region: showHelp || editingFolder ? "help" : region,
region: showHelp || editingFolder || editingTrackers ? "help" : region,
setRegion,
captureMode,
setCaptureMode,
@@ -307,6 +335,7 @@ export function App({
region,
showHelp,
editingFolder,
editingTrackers,
captureMode,
downloadFocus,
seedFocus,
@@ -328,7 +357,7 @@ export function App({
quitAll();
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 (showHelp) {
setShowHelp(false);
@@ -343,6 +372,11 @@ export function App({
setEditingFolder(true);
return;
}
if (input === "t") {
setShowHelp(false);
setEditingTrackers(true);
return;
}
if (input === "m") {
void pasteFromClipboard();
return;
@@ -420,10 +454,21 @@ export function App({
</Box>
) : 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
height={bodyH}
marginTop={compact ? 0 : 1}
display={showHelp || editingFolder ? "none" : "flex"}
display={showHelp || editingFolder || editingTrackers ? "none" : "flex"}
overflow="hidden"
>
<Sidebar />
@@ -439,7 +484,7 @@ export function App({
</Box>
{showFooter ? (
<Box display={showHelp || editingFolder ? "none" : "flex"}>
<Box display={showHelp || editingFolder || editingTrackers ? "none" : "flex"}>
<Footer hints={footerHints(region, section, downloadFocus, seedFocus)} />
</Box>
) : null}
+47
View File
@@ -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>
);
}
+1
View File
@@ -19,6 +19,7 @@ export const HELP_GROUPS: HelpGroup[] = [
{ keys: "tab", label: "Switch pane" },
{ keys: "esc", label: "Back" },
{ keys: "o", label: "Download folder" },
{ keys: "t", label: "Extra trackers" },
{ keys: "q", label: "Quit" },
],
},