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 {
|
||||
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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// 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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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: "esc", label: "Back" },
|
||||
{ keys: "o", label: "Download folder" },
|
||||
{ keys: "t", label: "Extra trackers" },
|
||||
{ keys: "q", label: "Quit" },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user