mirror of
https://github.com/baairon/torlink.git
synced 2026-07-08 18:28:22 +02:00
feat: seed downloads by default (opt-out) with robust resume and clean quit
Finished downloads now keep seeding automatically and you opt out per item with p, reusing the live torrent instead of tearing it down. Resume and restore seed from the .torrent metadata captured at download time, so they verify the local file immediately rather than depending on re-fetching metadata from the swarm. Quitting now flushes queue, history, and seeds synchronously and exits immediately, fixing the hang when quitting while seeding or with a paused seed. Paused seeds persist their state and stay paused across restarts. Hide the hardware cursor on the splash, and clarify that c removes a finished item the same way in Downloads and Seeding. Tests are isolated from the real data dir via a TORLINK_STATE_DIR override plus vitest config, so runs never touch user data. Adds scripts/verify-seeding.ts (npm run verify:seeding), an offline-deterministic harness proving seed, pause, and resume transfer real bytes.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<img src="preview/splash.svg" alt="torlink, curated torrents straight from your terminal" style="max-width: 832px; width: 100%; height: auto;">
|
||||
</p>
|
||||
|
||||
torlink is a torrent finder that lives in your terminal, with zero setup and nothing to configure. One search checks a short, curated list of reputable sources at once, and whatever you pick downloads straight to your computer, never seeding back out. The files are yours, saved to your downloads folder.
|
||||
torlink is a torrent finder that lives in your terminal, with zero setup and nothing to configure. One search checks a short, curated list of reputable sources at once, and whatever you pick downloads straight to your computer. The files are yours, saved to your downloads folder.
|
||||
|
||||
## Get started
|
||||
|
||||
@@ -34,7 +34,7 @@ Type what you're looking for and press Enter. Results stream in from every sourc
|
||||
|
||||
Active downloads sit up top with their progress, speed, and time left; when one finishes it drops into Recently downloaded just below, so the list stays tidy. Everything's still there when you come back, and anything interrupted picks up where it left off.
|
||||
|
||||
Downloads run in the background while you keep searching, so you can queue up as many as you want. They save to your downloads folder, and the Downloads pane keeps tabs on each one.
|
||||
Downloads run in the background while you keep searching, so you can queue up as many as you want. They save to your downloads folder, and the Downloads pane keeps tabs on each one. When something finishes it keeps seeding automatically so the next person can find it too, and the Seeding tab lets you pause or stop that anytime. Pressing `c` on a finished item removes it from your history and stops seeding it, the same whether you do it in Downloads or Seeding.
|
||||
|
||||
<p align="center">
|
||||
<img src="preview/downloads.svg" alt="torlink's Downloads pane: live progress on top, recently downloaded below" style="max-width: 832px; width: 100%; height: auto;">
|
||||
@@ -72,6 +72,10 @@ To run or work on torlink locally:
|
||||
npx torlink
|
||||
```
|
||||
|
||||
## Privacy
|
||||
|
||||
Your files stay on your disk, and nothing routes through a central server; torlink only talks to the torrent network directly. Once a download finishes it keeps seeding by default, sharing it back so the next person can find it just as easily. The network only works because people pass things along, and even a few minutes makes a real difference. If you'd rather not, opt out anytime: open the Seeding tab, press `p` to pause or stop any item, and press it again to pick it back up. Always your call.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=baairon%2Ftorlink&type=date&legend=top-left">
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"verify:seeding": "tsx scripts/verify-seeding.ts",
|
||||
"previews": "tsx scripts/render-previews.tsx",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<text x="60.8" y="326" textLength="86.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Downloads</text>
|
||||
<rect x="208.9" y="310" width="1.4" height="22" fill="#a78bfa"/>
|
||||
<rect x="784.9" y="310" width="1.4" height="22" fill="#a78bfa"/>
|
||||
<text x="60.8" y="348" textLength="67.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Seeding</text>
|
||||
<rect x="208.9" y="332" width="1.4" height="22" fill="#a78bfa"/>
|
||||
<text x="252.8" y="348" textLength="9.6" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55" font-weight="600">#</text>
|
||||
<text x="272" y="348" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55" font-weight="600">Name</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -94,6 +94,7 @@
|
||||
<text x="627.2" y="326" textLength="96" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">1d 1hr ago</text>
|
||||
<text x="732.8" y="326" textLength="38.4" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill="#f0c560" fill-opacity="0.55">EZTV</text>
|
||||
<rect x="784.9" y="310" width="1.4" height="22" fill="#a78bfa"/>
|
||||
<text x="60.8" y="348" textLength="67.2" lengthAdjust="spacingAndGlyphs" xml:space="preserve" fill-opacity="0.55">Seeding</text>
|
||||
<rect x="208.9" y="332" width="1.4" height="22" fill="#a78bfa"/>
|
||||
<rect x="784.9" y="332" width="1.4" height="22" fill="#a78bfa"/>
|
||||
<rect x="208.9" y="354" width="1.4" height="22" fill="#a78bfa"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
@@ -19,7 +19,7 @@ import { cleanText, formatBytes, formatRelative } from "../src/util/format";
|
||||
import { ansiToSvg, type AnsiToSvgOptions } from "./ansi-to-svg";
|
||||
import type { Config } from "../src/config/config";
|
||||
import type { DownloadQueue } from "../src/download/queue";
|
||||
import type { QueueItem } from "../src/download/types";
|
||||
import type { QueueItem, SeedItem } from "../src/download/types";
|
||||
import type { HistoryItem } from "../src/download/history";
|
||||
import type { TorrentResult } from "../src/sources/types";
|
||||
|
||||
@@ -50,12 +50,21 @@ const HISTORY: HistoryItem[] = [
|
||||
{ id: "h2", name: "Breaking Bad S05E14 1080p WEB-DL", source: "eztv", sizeBytes: 1.6e9, magnet: "", dir: "", completedAt: NOW_MS - 90_000_000 },
|
||||
];
|
||||
|
||||
function fakeQueue(items: QueueItem[], history: HistoryItem[]): DownloadQueue {
|
||||
function fakeQueue(
|
||||
items: QueueItem[],
|
||||
history: HistoryItem[],
|
||||
seeds: SeedItem[] = [],
|
||||
): DownloadQueue {
|
||||
const active = items.filter((i) => i.status === "downloading").length;
|
||||
const seedingCount = seeds.filter((s) => s.status === "seeding").length;
|
||||
const seedMap = new Map(seeds.map((s) => [s.id, s]));
|
||||
const stub = {
|
||||
getItems: () => items,
|
||||
getHistory: () => history,
|
||||
getSeeds: () => seeds,
|
||||
getSeed: (id: string) => seedMap.get(id),
|
||||
activeCount: active,
|
||||
seedingCount,
|
||||
on: () => stub,
|
||||
off: () => stub,
|
||||
};
|
||||
@@ -66,12 +75,13 @@ function makeStore(
|
||||
overrides: Partial<Store> = {},
|
||||
items: QueueItem[] = [],
|
||||
history: HistoryItem[] = [],
|
||||
seeds: SeedItem[] = [],
|
||||
): Store {
|
||||
const noop = (): void => {};
|
||||
return {
|
||||
config: { downloadDir: "~/Downloads/torlink" } as Config,
|
||||
setConfig: noop,
|
||||
queue: fakeQueue(items, history),
|
||||
queue: fakeQueue(items, history, seeds),
|
||||
view: "browser",
|
||||
setView: noop,
|
||||
query: "",
|
||||
@@ -84,6 +94,8 @@ function makeStore(
|
||||
setCaptureMode: noop,
|
||||
downloadFocus: null,
|
||||
setDownloadFocus: noop,
|
||||
seedFocus: null,
|
||||
setSeedFocus: noop,
|
||||
startDownload: noop,
|
||||
notice: null,
|
||||
setNotice: noop,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Proves torlink's seeding actually transfers bytes (not just a UI label), and
|
||||
* that pause and resume really stop/restart it.
|
||||
*
|
||||
* Offline-deterministic: the leechers run with dht/tracker/lsd OFF and are
|
||||
* wired straight to our seeder via addPeer("127.0.0.1:<port>"), so the ONLY
|
||||
* possible source of data is the TorrentEngine under test.
|
||||
*
|
||||
* npx tsx scripts/verify-seeding.ts
|
||||
*/
|
||||
import WebTorrent from "webtorrent";
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { TorrentEngine } from "../src/download/engine";
|
||||
|
||||
const FILE_BYTES = 4 * 1024 * 1024; // 4 MB
|
||||
const PULL_TIMEOUT = 20_000;
|
||||
const PAUSE_WINDOW = 6_000;
|
||||
|
||||
function log(msg: string): void {
|
||||
process.stdout.write(`${msg}\n`);
|
||||
}
|
||||
|
||||
function randomBytes(n: number): Buffer {
|
||||
// Deterministic-ish filler; content doesn't matter, only that it transfers.
|
||||
const buf = Buffer.allocUnsafe(n);
|
||||
for (let i = 0; i < n; i++) buf[i] = (i * 31 + 7) & 0xff;
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Spin a leecher wired directly to the seeder; resolve with bytes downloaded.
|
||||
// `source` is the .torrent path (metadata) so the leecher knows the pieces and
|
||||
// the only open question is whether our seeder actually serves them.
|
||||
function leech(
|
||||
source: string,
|
||||
port: number,
|
||||
dir: string,
|
||||
timeout: number,
|
||||
): Promise<{ done: boolean; downloaded: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const client = new WebTorrent({ dht: false, tracker: false, lsd: false });
|
||||
const torrent = client.add(source, { path: dir, announce: [] });
|
||||
let settled = false;
|
||||
let rewire: ReturnType<typeof setInterval> | null = null;
|
||||
const timer = setTimeout(() => finish(false), timeout);
|
||||
const finish = (done: boolean): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const downloaded = client.torrents[0]?.downloaded ?? 0;
|
||||
clearTimeout(timer);
|
||||
if (rewire) clearInterval(rewire);
|
||||
client.destroy(() => resolve({ done, downloaded }));
|
||||
};
|
||||
// A magnet has no metadata yet, so the leecher must learn it FROM this peer;
|
||||
// addPeer is only legal after the infoHash event. webtorrent's server binds
|
||||
// IPv6 (::), so use the v6 loopback (v4 too for good measure). Re-wire on a
|
||||
// timer: with discovery off, a dropped connection won't be re-found, so we
|
||||
// keep nudging the peer back in until the transfer completes.
|
||||
const wire = (): void => {
|
||||
torrent.addPeer(`[::1]:${port}`);
|
||||
torrent.addPeer(`127.0.0.1:${port}`);
|
||||
};
|
||||
torrent.on("infoHash", () => {
|
||||
wire();
|
||||
rewire = setInterval(wire, 1000);
|
||||
});
|
||||
torrent.on("done", () => finish(true));
|
||||
});
|
||||
}
|
||||
|
||||
// Wait until the engine reports the torrent fully verified (progress 1), so we
|
||||
// only start leeching once there is something complete to pull.
|
||||
async function waitSeederReady(
|
||||
engine: TorrentEngine,
|
||||
id: string,
|
||||
timeout: number,
|
||||
): Promise<{ progress: number; peers: number }> {
|
||||
const start = Date.now();
|
||||
for (;;) {
|
||||
const s = engine.stats(id);
|
||||
if (s && s.progress >= 1) return { progress: s.progress, peers: s.peers };
|
||||
if (Date.now() - start > timeout) return { progress: s?.progress ?? 0, peers: s?.peers ?? 0 };
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-seedcheck-"));
|
||||
const seedDir = path.join(root, "seed");
|
||||
await fs.mkdir(seedDir, { recursive: true });
|
||||
const filePath = path.join(seedDir, "payload.bin");
|
||||
await fs.writeFile(filePath, randomBytes(FILE_BYTES));
|
||||
log(`payload: ${filePath} (${FILE_BYTES} bytes)`);
|
||||
|
||||
// Mint the .torrent metadata for the on-disk file via a throwaway client, then
|
||||
// drop it. This is what torlink now captures at download time and seeds from.
|
||||
const minter = new WebTorrent({ dht: false, tracker: false, lsd: false });
|
||||
const { meta, infoHash } = await new Promise<{ meta: Uint8Array; infoHash: string }>(
|
||||
(resolve) => {
|
||||
minter.seed(filePath, { announce: [] }, (t) =>
|
||||
resolve({ meta: Uint8Array.from(t.torrentFile), infoHash: t.infoHash }),
|
||||
);
|
||||
},
|
||||
);
|
||||
await new Promise<void>((r) => minter.destroy(() => r()));
|
||||
const torrentPath = path.join(root, "meta.torrent");
|
||||
await fs.writeFile(torrentPath, meta);
|
||||
log(`metadata minted: ${infoHash}`);
|
||||
|
||||
const engine = new TorrentEngine();
|
||||
let failures = 0;
|
||||
const check = (label: string, ok: boolean, detail: string): void => {
|
||||
log(`${ok ? "PASS" : "FAIL"} ${label} ${detail}`);
|
||||
if (!ok) failures++;
|
||||
};
|
||||
|
||||
// 1) SEED — engine verifies the on-disk file from metadata and uploads it.
|
||||
engine.add(infoHash, torrentPath, seedDir, {
|
||||
onError: (m) => log(`seed onError: ${m}`),
|
||||
});
|
||||
const ready1 = await waitSeederReady(engine, infoHash, 10_000);
|
||||
const port1 = engine.listenPort();
|
||||
log(`engine seeding: progress=${ready1.progress} port=${port1}`);
|
||||
const r1 = await leech(torrentPath, port1 ?? 0, path.join(root, "leech1"), PULL_TIMEOUT);
|
||||
const s1 = engine.stats(infoHash);
|
||||
check(
|
||||
"seed uploads to a peer",
|
||||
r1.done && r1.downloaded === FILE_BYTES,
|
||||
`downloaded=${r1.downloaded}/${FILE_BYTES} done=${r1.done} engineUploaded=${s1?.uploaded ?? 0} peers=${s1?.peers ?? 0}`,
|
||||
);
|
||||
|
||||
// 2) PAUSE — engine.remove tears the torrent down; a fresh leecher gets nothing.
|
||||
engine.remove(infoHash);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const r2 = await leech(torrentPath, port1 ?? 0, path.join(root, "leech2"), PAUSE_WINDOW);
|
||||
check(
|
||||
"pause stops seeding",
|
||||
!r2.done && r2.downloaded === 0,
|
||||
`downloaded=${r2.downloaded} done=${r2.done} (expected 0 / false)`,
|
||||
);
|
||||
|
||||
// 3) RESUME — re-add and a fresh leecher completes again.
|
||||
engine.add(infoHash, torrentPath, seedDir, {
|
||||
onError: (m) => log(`resume onError: ${m}`),
|
||||
});
|
||||
const ready3 = await waitSeederReady(engine, infoHash, 10_000);
|
||||
const port3 = engine.listenPort();
|
||||
log(`engine re-seeding: progress=${ready3.progress} port=${port3}`);
|
||||
const r3 = await leech(torrentPath, port3 ?? 0, path.join(root, "leech3"), PULL_TIMEOUT);
|
||||
const s3 = engine.stats(infoHash);
|
||||
check(
|
||||
"resume re-seeds",
|
||||
r3.done && r3.downloaded === FILE_BYTES,
|
||||
`downloaded=${r3.downloaded}/${FILE_BYTES} done=${r3.done} engineUploaded=${s3?.uploaded ?? 0}`,
|
||||
);
|
||||
|
||||
engine.destroy();
|
||||
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
log("");
|
||||
log(failures === 0 ? "RESULT: seeding is REAL ✓ (seed/pause/resume verified)" : `RESULT: ${failures} check(s) FAILED`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
log(`harness error: ${e instanceof Error ? e.stack : String(e)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
+17
-4
@@ -4,12 +4,25 @@ import envPaths from "env-paths";
|
||||
|
||||
export const APP_NAME = "torlink";
|
||||
|
||||
export const paths = envPaths(APP_NAME, { suffix: "" });
|
||||
const base = envPaths(APP_NAME, { suffix: "" });
|
||||
|
||||
// Optional override that relocates all persisted state under one folder. Tests
|
||||
// point this at a temp dir so they never touch the real user data; it also
|
||||
// doubles as a portable-state escape hatch. Off unless the env var is set.
|
||||
const override = process.env.TORLINK_STATE_DIR;
|
||||
const dataDir = override ? path.join(override, "data") : base.data;
|
||||
const configDir = override ? path.join(override, "config") : base.config;
|
||||
|
||||
export const defaultDownloadDir = path.join(os.homedir(), "Downloads", APP_NAME);
|
||||
|
||||
export const configFile = path.join(paths.config, "config.json");
|
||||
export const configFile = path.join(configDir, "config.json");
|
||||
|
||||
export const queueFile = path.join(paths.data, "queue.json");
|
||||
export const queueFile = path.join(dataDir, "queue.json");
|
||||
|
||||
export const historyFile = path.join(paths.data, "history.json");
|
||||
export const historyFile = path.join(dataDir, "history.json");
|
||||
|
||||
export const seedsFile = path.join(dataDir, "seeds.json");
|
||||
|
||||
// Per-torrent .torrent metadata, captured during download so a re-seed can
|
||||
// verify the on-disk file locally instead of re-fetching it from the swarm.
|
||||
export const torrentsDir = path.join(dataDir, "torrents");
|
||||
|
||||
+31
-9
@@ -5,6 +5,8 @@ export interface TorrentProgress {
|
||||
downloaded: number;
|
||||
total: number;
|
||||
speed: number;
|
||||
uploadSpeed: number;
|
||||
uploaded: number;
|
||||
peers: number;
|
||||
timeRemaining: number;
|
||||
name: string;
|
||||
@@ -14,6 +16,10 @@ export interface TorrentMeta {
|
||||
name: string;
|
||||
total: number;
|
||||
files: number;
|
||||
// The .torrent metadata (piece hashes), available once metadata arrives. We
|
||||
// persist it so a later re-seed can verify the on-disk file without having to
|
||||
// re-fetch metadata from the swarm (which a bare magnet would require).
|
||||
torrentFile?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface AddHandlers {
|
||||
@@ -38,7 +44,10 @@ export class TorrentEngine {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
add(id: string, magnet: string, dir: string, handlers: AddHandlers): void {
|
||||
// `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 {
|
||||
const client = this.ensureClient();
|
||||
const existing = this.torrents.get(id);
|
||||
if (existing) {
|
||||
@@ -50,7 +59,7 @@ export class TorrentEngine {
|
||||
|
||||
let torrent: Torrent;
|
||||
try {
|
||||
torrent = client.add(magnet, { path: dir });
|
||||
torrent = client.add(source, { path: dir });
|
||||
} catch (e) {
|
||||
handlers.onError?.(message(e));
|
||||
return;
|
||||
@@ -62,14 +71,13 @@ export class TorrentEngine {
|
||||
name: torrent.name,
|
||||
total: torrent.length,
|
||||
files: torrent.files?.length ?? 0,
|
||||
torrentFile: torrent.torrentFile,
|
||||
});
|
||||
});
|
||||
torrent.on("done", () => {
|
||||
// A finished torrent is a complete, verified torrent: keep it alive so it
|
||||
// can seed. The queue owns its lifetime from here (remove/destroy).
|
||||
handlers.onDone?.();
|
||||
this.torrents.delete(id);
|
||||
try {
|
||||
torrent.destroy();
|
||||
} catch {}
|
||||
});
|
||||
torrent.on("error", (err: unknown) => {
|
||||
handlers.onError?.(message(err));
|
||||
@@ -80,6 +88,11 @@ export class TorrentEngine {
|
||||
});
|
||||
}
|
||||
|
||||
// The TCP port the client accepts incoming peers on (diagnostics / tests).
|
||||
listenPort(): number | null {
|
||||
return this.client?.torrentPort ?? null;
|
||||
}
|
||||
|
||||
stats(id: string): TorrentProgress | null {
|
||||
const t = this.torrents.get(id);
|
||||
if (!t) return null;
|
||||
@@ -88,6 +101,8 @@ export class TorrentEngine {
|
||||
downloaded: t.downloaded,
|
||||
total: t.length,
|
||||
speed: t.downloadSpeed,
|
||||
uploadSpeed: t.uploadSpeed,
|
||||
uploaded: t.uploaded,
|
||||
peers: t.numPeers,
|
||||
timeRemaining: t.timeRemaining,
|
||||
name: t.name,
|
||||
@@ -106,9 +121,16 @@ export class TorrentEngine {
|
||||
|
||||
destroy(): void {
|
||||
this.torrents.clear();
|
||||
try {
|
||||
this.client?.destroy();
|
||||
} catch {}
|
||||
// Never block shutdown on webtorrent's async teardown: hand off the client
|
||||
// destroy to a later tick and let the OS reclaim sockets if we exit first.
|
||||
const client = this.client;
|
||||
this.client = null;
|
||||
if (client) {
|
||||
setImmediate(() => {
|
||||
try {
|
||||
client.destroy();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -1,4 +1,5 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { promises as fs, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { historyFile } from "../config/paths";
|
||||
import { serializeWrites, writeJsonAtomic } from "../util/atomic";
|
||||
import type { SourceId } from "../sources/types";
|
||||
@@ -21,6 +22,15 @@ export function saveHistory(items: HistoryItem[]): Promise<void> {
|
||||
return write(() => writeJsonAtomic(historyFile, items.slice(0, HISTORY_CAP)));
|
||||
}
|
||||
|
||||
export function saveHistorySync(items: HistoryItem[]): void {
|
||||
try {
|
||||
mkdirSync(path.dirname(historyFile), { recursive: true });
|
||||
const tmp = `${historyFile}.sync.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(items.slice(0, HISTORY_CAP), null, 2), "utf8");
|
||||
renameSync(tmp, historyFile);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isHistoryItem(v: unknown): v is HistoryItem {
|
||||
if (!v || typeof v !== "object") return false;
|
||||
const r = v as Record<string, unknown>;
|
||||
|
||||
+79
-2
@@ -1,6 +1,6 @@
|
||||
import { promises as fs, mkdirSync, writeFileSync, renameSync } from "node:fs";
|
||||
import { promises as fs, mkdirSync, writeFileSync, renameSync, existsSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { queueFile } from "../config/paths";
|
||||
import { queueFile, seedsFile, torrentsDir } from "../config/paths";
|
||||
import { serializeWrites, writeJsonAtomic } from "../util/atomic";
|
||||
import type { QueueItem } from "./types";
|
||||
|
||||
@@ -39,3 +39,80 @@ export async function loadQueue(): Promise<QueueItem[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// We persist the user-meaningful seed states so a deliberate pause survives a
|
||||
// restart (and stays paused), not just the seeding ids. Missing is runtime-only
|
||||
// and gets folded to paused on the way out so a gone file is never auto-seeded.
|
||||
export type PersistedSeedStatus = "seeding" | "paused";
|
||||
|
||||
export interface SeedRecord {
|
||||
id: string;
|
||||
status: PersistedSeedStatus;
|
||||
}
|
||||
|
||||
export function saveSeeds(records: SeedRecord[]): Promise<void> {
|
||||
return write(() => writeJsonAtomic(seedsFile, records));
|
||||
}
|
||||
|
||||
export function saveSeedsSync(records: SeedRecord[]): void {
|
||||
try {
|
||||
mkdirSync(path.dirname(seedsFile), { recursive: true });
|
||||
const tmp = `${seedsFile}.sync.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(records, null, 2), "utf8");
|
||||
renameSync(tmp, seedsFile);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// --- per-torrent .torrent metadata cache ------------------------------------
|
||||
|
||||
export function torrentMetaPath(id: string): string {
|
||||
return path.join(torrentsDir, `${id}.torrent`);
|
||||
}
|
||||
|
||||
export function torrentMetaExists(id: string): boolean {
|
||||
return existsSync(torrentMetaPath(id));
|
||||
}
|
||||
|
||||
export async function saveTorrentMeta(id: string, data: Uint8Array): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(torrentsDir, { recursive: true });
|
||||
const file = torrentMetaPath(id);
|
||||
const tmp = `${file}.tmp`;
|
||||
await fs.writeFile(tmp, data);
|
||||
await fs.rename(tmp, file);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function deleteTorrentMeta(id: string): void {
|
||||
try {
|
||||
rmSync(torrentMetaPath(id), { force: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function loadSeeds(): Promise<SeedRecord[]> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(seedsFile, "utf8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
const out: SeedRecord[] = [];
|
||||
for (const el of parsed) {
|
||||
// Legacy format was a bare id array; treat each as a seeding entry.
|
||||
if (typeof el === "string") {
|
||||
out.push({ id: el, status: "seeding" });
|
||||
} else if (el && typeof el === "object") {
|
||||
const r = el as Record<string, unknown>;
|
||||
if (typeof r.id === "string" && (r.status === "seeding" || r.status === "paused")) {
|
||||
out.push({ id: r.id, status: r.status });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { DownloadQueue, strayDownload } from "./queue";
|
||||
import type { HistoryItem } from "./history";
|
||||
|
||||
function h(over: Partial<HistoryItem> = {}): HistoryItem {
|
||||
return {
|
||||
id: "h1",
|
||||
name: "Some Download",
|
||||
magnet: "magnet:?xt=urn:btih:0000000000000000000000000000000000000000",
|
||||
dir: "/downloads",
|
||||
sizeBytes: 100,
|
||||
completedAt: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DownloadQueue seeding", () => {
|
||||
it("refuses to seed an entry with no magnet (the only synchronous guard)", () => {
|
||||
const q = new DownloadQueue();
|
||||
q.startSeeding(h({ id: "h2", magnet: "" }));
|
||||
expect(q.getSeed("h2")?.status).toBe("missing");
|
||||
expect(q.seedingCount).toBe(0);
|
||||
q.suspend();
|
||||
});
|
||||
|
||||
it("persistSync flushes every state file without touching the engine", () => {
|
||||
const q = new DownloadQueue();
|
||||
q.restoreHistory([h({ id: "h3" })]);
|
||||
// No engine work, so this never spins up webtorrent and never throws even
|
||||
// with a populated history.
|
||||
expect(() => q.persistSync()).not.toThrow();
|
||||
});
|
||||
|
||||
it("restores a paused seed as paused and does not auto-start it", () => {
|
||||
const q = new DownloadQueue();
|
||||
q.restoreHistory([h({ id: "h4" })]);
|
||||
// A deliberately paused seed must come back paused (visible), not seeding,
|
||||
// and without spinning up the engine.
|
||||
q.restoreSeeds([{ id: "h4", status: "paused" }]);
|
||||
expect(q.getSeed("h4")?.status).toBe("paused");
|
||||
expect(q.seedingCount).toBe(0);
|
||||
q.suspend();
|
||||
});
|
||||
});
|
||||
|
||||
describe("strayDownload (missing-file safety-net)", () => {
|
||||
it("ignores a present file being verified (disk read, no network speed)", () => {
|
||||
// Large file mid-verify: progress < 1 but network speed is 0.
|
||||
expect(strayDownload({ total: 50e9, progress: 0.4, speed: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores a complete, healthy seed", () => {
|
||||
expect(strayDownload({ total: 8e9, progress: 1, speed: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("flags a seed that is actually pulling missing data off the network", () => {
|
||||
expect(strayDownload({ total: 8e9, progress: 0.2, speed: 2e6 })).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores a seed before metadata has arrived (total unknown)", () => {
|
||||
expect(strayDownload({ total: 0, progress: 0, speed: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
+305
-24
@@ -1,10 +1,39 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { TorrentEngine } from "./engine";
|
||||
import { saveQueue, saveQueueSync } from "./persist";
|
||||
import { saveHistory, type HistoryItem } from "./history";
|
||||
import type { QueueItem } from "./types";
|
||||
import { TorrentEngine, type AddHandlers } from "./engine";
|
||||
import {
|
||||
saveQueue,
|
||||
saveQueueSync,
|
||||
saveSeeds,
|
||||
saveSeedsSync,
|
||||
saveTorrentMeta,
|
||||
torrentMetaPath,
|
||||
torrentMetaExists,
|
||||
deleteTorrentMeta,
|
||||
type SeedRecord,
|
||||
} from "./persist";
|
||||
import { saveHistory, saveHistorySync, type HistoryItem } from "./history";
|
||||
import type { QueueItem, SeedItem } from "./types";
|
||||
import type { SourceId } from "../sources/types";
|
||||
|
||||
/**
|
||||
* A real seed never pulls data off the network: verifying on-disk files reads
|
||||
* the disk (network speed stays 0), only fetching *missing* data raises it. So
|
||||
* sustained network download on a "seed" means its files are gone or partial.
|
||||
* Size-agnostic (a 50 GB verify never trips it) and cross-platform (webtorrent
|
||||
* owns the real on-disk paths, so we never guess sanitized filenames).
|
||||
*/
|
||||
export function strayDownload(s: { total: number; progress: number; speed: number }): boolean {
|
||||
return s.total > 0 && s.progress < 1 && s.speed > 0;
|
||||
}
|
||||
|
||||
const STRAY_TICKS = 2; // consecutive stray polls before flagging missing (~1s)
|
||||
|
||||
// How long (ms) to let webtorrent verify on-disk pieces before the stray-download
|
||||
// detector starts watching. Verification reads the disk and can briefly report
|
||||
// downloadSpeed > 0 / progress < 1, which is indistinguishable from a truly
|
||||
// missing file. 10 s covers most single-torrent verifications comfortably.
|
||||
const SEED_GRACE_MS = 10_000;
|
||||
|
||||
const POLL_MS = 500;
|
||||
const HISTORY_MAX = 500;
|
||||
|
||||
@@ -21,6 +50,9 @@ export class DownloadQueue extends EventEmitter {
|
||||
private engine = new TorrentEngine();
|
||||
private poll: ReturnType<typeof setInterval> | null = null;
|
||||
private history: HistoryItem[] = [];
|
||||
private seeds = new Map<string, SeedItem>();
|
||||
private strayHits = new Map<string, number>();
|
||||
private seedStartedAt = new Map<string, number>();
|
||||
|
||||
getItems(): QueueItem[] {
|
||||
return [...this.items.values()].sort((a, b) => b.addedAt - a.addedAt);
|
||||
@@ -37,6 +69,13 @@ export class DownloadQueue extends EventEmitter {
|
||||
}
|
||||
|
||||
add(input: AddInput, dir: string): void {
|
||||
if (this.seeds.has(input.id)) {
|
||||
this.engine.remove(input.id);
|
||||
this.seeds.delete(input.id);
|
||||
this.strayHits.delete(input.id);
|
||||
this.seedStartedAt.delete(input.id);
|
||||
void this.persistSeeds();
|
||||
}
|
||||
const existing = this.items.get(input.id);
|
||||
if (existing && existing.status !== "failed") return;
|
||||
const item: QueueItem = existing
|
||||
@@ -63,10 +102,22 @@ export class DownloadQueue extends EventEmitter {
|
||||
}
|
||||
|
||||
private startEngine(item: QueueItem): void {
|
||||
this.engine.add(item.id, item.magnet, item.dir, {
|
||||
this.engine.add(item.id, item.magnet, item.dir, this.engineHandlers(item.id));
|
||||
}
|
||||
|
||||
// One torrent serves an item across its whole life (download -> seed ->
|
||||
// missing), so the engine handlers are phase-aware: they look up the id in
|
||||
// `items` (still downloading) or `seeds` (finished, now seeding) and act on
|
||||
// whichever it currently is.
|
||||
private engineHandlers(id: string): AddHandlers {
|
||||
return {
|
||||
onMetadata: (meta) => {
|
||||
const it = this.items.get(item.id);
|
||||
if (!it) return;
|
||||
// Capture the .torrent metadata as soon as it arrives so a later re-seed
|
||||
// can verify the on-disk file locally (a bare magnet would have to
|
||||
// re-fetch this from the swarm, which fails for rare/dead torrents).
|
||||
if (meta.torrentFile) void saveTorrentMeta(id, meta.torrentFile);
|
||||
const it = this.items.get(id);
|
||||
if (!it) return; // the rest only matters while still downloading
|
||||
if (meta.name) it.name = meta.name;
|
||||
if (meta.total) it.totalBytes = meta.total;
|
||||
it.files = meta.files;
|
||||
@@ -74,34 +125,81 @@ export class DownloadQueue extends EventEmitter {
|
||||
void this.persist();
|
||||
},
|
||||
onDone: () => {
|
||||
const it = this.items.get(item.id);
|
||||
if (!it) return;
|
||||
if (it.totalBytes) it.downloadedBytes = it.totalBytes;
|
||||
this.complete(it);
|
||||
const it = this.items.get(id);
|
||||
if (it) {
|
||||
// Download finished: record it and keep the torrent seeding.
|
||||
if (it.totalBytes) it.downloadedBytes = it.totalBytes;
|
||||
this.complete(it);
|
||||
return;
|
||||
}
|
||||
// A re-seed (restart / manual resume) passed verification: the file is
|
||||
// confirmed on disk, so clear stray-detection state and end its grace.
|
||||
if (this.seeds.has(id)) {
|
||||
this.strayHits.set(id, 0);
|
||||
this.seedStartedAt.delete(id);
|
||||
}
|
||||
},
|
||||
onError: (msg) => {
|
||||
const it = this.items.get(item.id);
|
||||
if (!it) return;
|
||||
it.status = "failed";
|
||||
it.error = msg;
|
||||
it.speed = 0;
|
||||
it.peers = 0;
|
||||
this.changed();
|
||||
void this.persist();
|
||||
this.maybeStopPoll();
|
||||
const it = this.items.get(id);
|
||||
if (it) {
|
||||
it.status = "failed";
|
||||
it.error = msg;
|
||||
it.speed = 0;
|
||||
it.peers = 0;
|
||||
this.changed();
|
||||
void this.persist();
|
||||
this.maybeStopPoll();
|
||||
return;
|
||||
}
|
||||
const sd = this.seeds.get(id);
|
||||
if (sd) {
|
||||
sd.status = "missing";
|
||||
sd.uploadSpeed = 0;
|
||||
sd.peers = 0;
|
||||
this.seedStartedAt.delete(id);
|
||||
this.changed();
|
||||
void this.persistSeeds();
|
||||
this.maybeStopPoll();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private complete(it: QueueItem): void {
|
||||
this.recordHistory(it);
|
||||
this.items.delete(it.id);
|
||||
// Opt-out seeding: a finished download is already a complete, verified
|
||||
// torrent, so keep it alive and seeding instead of tearing it down.
|
||||
this.beginSeed(it);
|
||||
this.emit("completed", it.name);
|
||||
this.changed();
|
||||
void this.persist();
|
||||
this.maybeStopPoll();
|
||||
}
|
||||
|
||||
// Adopt the just-finished download's live torrent as a seed in place: no
|
||||
// re-add, no re-verify (progress is already 1, so stray detection never
|
||||
// trips). Restart / manual resume go through startSeeding instead.
|
||||
private beginSeed(it: QueueItem): void {
|
||||
if (!it.magnet) return;
|
||||
this.seeds.set(it.id, {
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
source: it.source,
|
||||
magnet: it.magnet,
|
||||
dir: it.dir,
|
||||
sizeBytes: it.totalBytes,
|
||||
status: "seeding",
|
||||
uploadSpeed: 0,
|
||||
uploaded: 0,
|
||||
peers: 0,
|
||||
});
|
||||
this.strayHits.set(it.id, 0);
|
||||
this.seedStartedAt.set(it.id, Date.now());
|
||||
this.ensurePoll();
|
||||
void this.persistSeeds();
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
let any = false;
|
||||
for (const it of this.items.values()) {
|
||||
@@ -120,6 +218,40 @@ export class DownloadQueue extends EventEmitter {
|
||||
if (s.name) it.name = s.name;
|
||||
any = true;
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const sd of this.seeds.values()) {
|
||||
if (sd.status !== "seeding") continue;
|
||||
const s = this.engine.stats(sd.id);
|
||||
if (!s) continue;
|
||||
// Safety-net: a seed that's pulling data has lost its files on disk. Give
|
||||
// it a couple of ticks (ignore a one-piece repair blip), then stop it and
|
||||
// flag missing, never re-download the whole thing.
|
||||
//
|
||||
// Skip seeds still inside the grace period: webtorrent needs time to
|
||||
// hash-verify on-disk pieces, and during that window progress < 1 with
|
||||
// downloadSpeed > 0 is perfectly normal.
|
||||
const age = now - (this.seedStartedAt.get(sd.id) ?? 0);
|
||||
if (age > SEED_GRACE_MS && strayDownload(s)) {
|
||||
const hits = (this.strayHits.get(sd.id) ?? 0) + 1;
|
||||
this.strayHits.set(sd.id, hits);
|
||||
if (hits >= STRAY_TICKS) {
|
||||
this.engine.remove(sd.id);
|
||||
this.strayHits.delete(sd.id);
|
||||
this.seedStartedAt.delete(sd.id);
|
||||
sd.status = "missing";
|
||||
sd.uploadSpeed = 0;
|
||||
sd.peers = 0;
|
||||
void this.persistSeeds();
|
||||
}
|
||||
any = true;
|
||||
continue;
|
||||
}
|
||||
this.strayHits.set(sd.id, 0);
|
||||
sd.uploadSpeed = s.uploadSpeed;
|
||||
sd.uploaded = s.uploaded;
|
||||
sd.peers = s.peers;
|
||||
any = true;
|
||||
}
|
||||
if (any) this.changed();
|
||||
}
|
||||
|
||||
@@ -130,7 +262,7 @@ export class DownloadQueue extends EventEmitter {
|
||||
}
|
||||
|
||||
private maybeStopPoll(): void {
|
||||
if (this.activeCount === 0 && this.poll) {
|
||||
if (this.activeCount === 0 && this.seedingCount === 0 && this.poll) {
|
||||
clearInterval(this.poll);
|
||||
this.poll = null;
|
||||
}
|
||||
@@ -170,6 +302,7 @@ export class DownloadQueue extends EventEmitter {
|
||||
if (!this.items.has(id)) return;
|
||||
this.engine.remove(id);
|
||||
this.items.delete(id);
|
||||
deleteTorrentMeta(id);
|
||||
this.changed();
|
||||
void this.persist();
|
||||
this.maybeStopPoll();
|
||||
@@ -192,6 +325,125 @@ export class DownloadQueue extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
getSeed(id: string): SeedItem | undefined {
|
||||
return this.seeds.get(id);
|
||||
}
|
||||
|
||||
getSeeds(): SeedItem[] {
|
||||
return [...this.seeds.values()];
|
||||
}
|
||||
|
||||
get seedingCount(): number {
|
||||
let n = 0;
|
||||
for (const s of this.seeds.values()) if (s.status === "seeding") n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
startSeeding(h: HistoryItem): void {
|
||||
if (this.seeds.get(h.id)?.status === "seeding") return;
|
||||
if (this.items.has(h.id)) return; // don't seed a file that's downloading
|
||||
|
||||
const base: SeedItem = {
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
source: h.source,
|
||||
magnet: h.magnet,
|
||||
dir: h.dir,
|
||||
sizeBytes: h.sizeBytes,
|
||||
status: "seeding",
|
||||
uploadSpeed: 0,
|
||||
uploaded: 0,
|
||||
peers: 0,
|
||||
};
|
||||
|
||||
// Only hard guard we can make synchronously and portably: no magnet, no seed.
|
||||
// We do NOT guess the on-disk path (webtorrent sanitizes names per-OS); we
|
||||
// let it verify the real files and the poll safety-net flags a missing one.
|
||||
if (!h.magnet) {
|
||||
this.seeds.set(h.id, { ...base, status: "missing" });
|
||||
this.changed();
|
||||
void this.persistSeeds();
|
||||
return;
|
||||
}
|
||||
|
||||
this.seeds.set(h.id, base);
|
||||
this.strayHits.set(h.id, 0);
|
||||
this.seedStartedAt.set(h.id, Date.now());
|
||||
// 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.ensurePoll();
|
||||
this.changed();
|
||||
void this.persistSeeds();
|
||||
}
|
||||
|
||||
stopSeeding(id: string): void {
|
||||
const s = this.seeds.get(id);
|
||||
if (!s) return;
|
||||
this.engine.remove(id);
|
||||
this.strayHits.delete(id);
|
||||
this.seedStartedAt.delete(id);
|
||||
if (s.status === "seeding") {
|
||||
s.status = "paused";
|
||||
s.uploadSpeed = 0;
|
||||
s.peers = 0;
|
||||
}
|
||||
this.changed();
|
||||
void this.persistSeeds();
|
||||
this.maybeStopPoll();
|
||||
}
|
||||
|
||||
toggleSeeding(h: HistoryItem): void {
|
||||
if (this.seeds.get(h.id)?.status === "seeding") this.stopSeeding(h.id);
|
||||
else this.startSeeding(h);
|
||||
}
|
||||
|
||||
restoreSeeds(records: SeedRecord[]): void {
|
||||
for (const r of records) {
|
||||
const h = this.history.find((x) => x.id === r.id);
|
||||
if (!h) continue;
|
||||
// Respect the persisted choice: resume seeders, but leave a paused seed
|
||||
// paused (and visibly so) instead of auto-starting it.
|
||||
if (r.status === "seeding") this.startSeeding(h);
|
||||
else this.restorePaused(h);
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild a paused seed from history without touching the engine, so it shows
|
||||
// as paused and stays off until the user presses p to resume it.
|
||||
private restorePaused(h: HistoryItem): void {
|
||||
if (this.seeds.has(h.id)) return;
|
||||
this.seeds.set(h.id, {
|
||||
id: h.id,
|
||||
name: h.name,
|
||||
source: h.source,
|
||||
magnet: h.magnet,
|
||||
dir: h.dir,
|
||||
sizeBytes: h.sizeBytes,
|
||||
status: "paused",
|
||||
uploadSpeed: 0,
|
||||
uploaded: 0,
|
||||
peers: 0,
|
||||
});
|
||||
this.changed();
|
||||
}
|
||||
|
||||
private seedRecords(): SeedRecord[] {
|
||||
const out: SeedRecord[] = [];
|
||||
for (const s of this.seeds.values()) {
|
||||
// "missing" is a runtime detection (file gone); persist it as paused so we
|
||||
// remember the user had it without auto-seeding a file that isn't there.
|
||||
if (s.status === "seeding") out.push({ id: s.id, status: "seeding" });
|
||||
else out.push({ id: s.id, status: "paused" });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private persistSeeds(): Promise<void> {
|
||||
return saveSeeds(this.seedRecords()).catch(() => {});
|
||||
}
|
||||
|
||||
restore(items: QueueItem[]): void {
|
||||
for (const raw of items) {
|
||||
this.items.set(raw.id, raw);
|
||||
@@ -227,13 +479,31 @@ export class DownloadQueue extends EventEmitter {
|
||||
const next = this.history.filter((h) => h.id !== id);
|
||||
if (next.length === this.history.length) return;
|
||||
this.history = next;
|
||||
if (this.seeds.has(id)) {
|
||||
this.engine.remove(id);
|
||||
this.seeds.delete(id);
|
||||
this.strayHits.delete(id);
|
||||
this.seedStartedAt.delete(id);
|
||||
void this.persistSeeds();
|
||||
this.maybeStopPoll();
|
||||
}
|
||||
deleteTorrentMeta(id);
|
||||
void saveHistory(this.history).catch(() => {});
|
||||
this.changed();
|
||||
}
|
||||
|
||||
clearHistory(): void {
|
||||
if (this.history.length === 0) return;
|
||||
for (const h of this.history) deleteTorrentMeta(h.id);
|
||||
this.history = [];
|
||||
if (this.seeds.size > 0) {
|
||||
for (const id of this.seeds.keys()) this.engine.remove(id);
|
||||
this.seeds.clear();
|
||||
this.strayHits.clear();
|
||||
this.seedStartedAt.clear();
|
||||
void this.persistSeeds();
|
||||
this.maybeStopPoll();
|
||||
}
|
||||
void saveHistory(this.history).catch(() => {});
|
||||
this.changed();
|
||||
}
|
||||
@@ -246,16 +516,27 @@ export class DownloadQueue extends EventEmitter {
|
||||
await saveQueue(this.getItems()).catch(() => {});
|
||||
}
|
||||
|
||||
// Synchronously flush every state file from current memory. Used on quit so
|
||||
// nothing depends on in-flight async writes surviving the hard exit, and so
|
||||
// history / seeds can never be lost mid-write. Touches no engine state, so it
|
||||
// can never block shutdown.
|
||||
persistSync(): void {
|
||||
saveQueueSync(this.getItems());
|
||||
saveHistorySync(this.history);
|
||||
saveSeedsSync(this.seedRecords());
|
||||
}
|
||||
|
||||
suspend(): void {
|
||||
// Keep active downloads as "downloading" so restore() resumes them on the
|
||||
// next launch (mirroring how seeds auto-restore); just zero the live stats.
|
||||
for (const it of this.items.values()) {
|
||||
if (it.status === "downloading") {
|
||||
it.status = "paused";
|
||||
it.speed = 0;
|
||||
it.peers = 0;
|
||||
it.eta = undefined;
|
||||
}
|
||||
}
|
||||
saveQueueSync(this.getItems());
|
||||
this.persistSync();
|
||||
if (this.poll) {
|
||||
clearInterval(this.poll);
|
||||
this.poll = null;
|
||||
|
||||
@@ -2,6 +2,21 @@ import type { SourceId } from "../sources/types";
|
||||
|
||||
export type DownloadStatus = "downloading" | "paused" | "completed" | "failed";
|
||||
|
||||
export type SeedStatus = "seeding" | "paused" | "missing";
|
||||
|
||||
export interface SeedItem {
|
||||
id: string;
|
||||
name: string;
|
||||
source?: SourceId;
|
||||
magnet: string;
|
||||
dir: string;
|
||||
sizeBytes: number;
|
||||
status: SeedStatus;
|
||||
uploadSpeed: number;
|
||||
uploaded: number;
|
||||
peers: number;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
+14
-3
@@ -21,7 +21,10 @@ if (cmd.kind === "invalid") {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write("\x1b[?1049h\x1b[22;0t\x1b]0;torlink\x07");
|
||||
// Enter the alt-screen and hide the hardware cursor: the TUI draws its own
|
||||
// cursor (the search field block, list pointers), so the terminal's should
|
||||
// stay hidden. restoreTerminal shows it again on exit.
|
||||
process.stdout.write("\x1b[?1049h\x1b[?25l\x1b[22;0t\x1b]0;torlink\x07");
|
||||
if (process.platform === "win32") process.title = "torlink";
|
||||
|
||||
let restored = false;
|
||||
@@ -33,13 +36,21 @@ function restoreTerminal(): void {
|
||||
|
||||
let exiting = false;
|
||||
function forceExit(code = 0): void {
|
||||
if (exiting) return;
|
||||
// Re-entry (e.g. ctrl-c after q): never get stuck, just leave now.
|
||||
if (exiting) {
|
||||
restoreTerminal();
|
||||
process.exit(code);
|
||||
}
|
||||
exiting = true;
|
||||
// Exit synchronously and unconditionally. State is already flushed
|
||||
// (quitAll -> persistSync, and the unmount effect runs suspend()), so we never
|
||||
// wait on webtorrent releasing its sockets; the OS reclaims them. Unmount
|
||||
// first to restore raw mode, then our own terminal sequences, then go.
|
||||
try {
|
||||
app?.unmount();
|
||||
} catch {}
|
||||
restoreTerminal();
|
||||
setTimeout(() => process.exit(code), 80).unref();
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const app = render(
|
||||
|
||||
+19
-4
@@ -3,7 +3,7 @@ import { Box, Text, useApp, useInput, useStdout, useStdin } from "ink";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { loadConfig, saveConfig, type Config } from "../config/config";
|
||||
import { DownloadQueue } from "../download/queue";
|
||||
import { loadQueue } from "../download/persist";
|
||||
import { loadQueue, loadSeeds } from "../download/persist";
|
||||
import { loadHistory } from "../download/history";
|
||||
import { reconcileQueue } from "../download/reconcile";
|
||||
import { parseMagnet } from "../sources/magnet";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type DownloadFocus,
|
||||
type Region,
|
||||
type Section,
|
||||
type SeedFocus,
|
||||
type Store,
|
||||
type View,
|
||||
} from "./store";
|
||||
@@ -26,6 +27,7 @@ import { Footer } from "./components/Footer";
|
||||
import { HelpOverlay } from "./components/HelpOverlay";
|
||||
import { Results } from "./components/Results";
|
||||
import { Downloads } from "./components/Downloads";
|
||||
import { Seeding } from "./components/Seeding";
|
||||
import { Spinner } from "./components/Spinner";
|
||||
import { TabTitle } from "./components/TabTitle";
|
||||
import { Splash } from "./views/Splash";
|
||||
@@ -76,6 +78,7 @@ export function App({
|
||||
const [region, setRegion] = useState<Region>("content");
|
||||
const [captureMode, setCaptureMode] = useState<CaptureMode>("none");
|
||||
const [downloadFocus, setDownloadFocus] = useState<DownloadFocus | null>(null);
|
||||
const [seedFocus, setSeedFocus] = useState<SeedFocus | null>(null);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const booting = useRef(false);
|
||||
@@ -89,6 +92,7 @@ export function App({
|
||||
const q = new DownloadQueue();
|
||||
q.restore(reconcileQueue(await loadQueue()));
|
||||
q.restoreHistory(await loadHistory());
|
||||
q.restoreSeeds(await loadSeeds());
|
||||
if (!alive) {
|
||||
q.suspend();
|
||||
return;
|
||||
@@ -134,7 +138,9 @@ export function App({
|
||||
);
|
||||
|
||||
const quitAll = useCallback(() => {
|
||||
queue?.suspend();
|
||||
// Flush all state synchronously up front so nothing is lost to the hard
|
||||
// exit; the unmount effect still runs suspend() for the engine teardown.
|
||||
queue?.persistSync();
|
||||
if (onQuit) onQuit();
|
||||
else exit();
|
||||
}, [queue, onQuit, exit]);
|
||||
@@ -238,6 +244,8 @@ export function App({
|
||||
setCaptureMode,
|
||||
downloadFocus,
|
||||
setDownloadFocus,
|
||||
seedFocus,
|
||||
setSeedFocus,
|
||||
startDownload,
|
||||
notice,
|
||||
setNotice,
|
||||
@@ -259,6 +267,7 @@ export function App({
|
||||
showHelp,
|
||||
captureMode,
|
||||
downloadFocus,
|
||||
seedFocus,
|
||||
startDownload,
|
||||
notice,
|
||||
listRows,
|
||||
@@ -351,13 +360,19 @@ export function App({
|
||||
>
|
||||
<Sidebar />
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
{section === "downloads" ? <Downloads /> : <Results />}
|
||||
{section === "downloads" ? (
|
||||
<Downloads />
|
||||
) : section === "seeding" ? (
|
||||
<Seeding />
|
||||
) : (
|
||||
<Results />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showFooter ? (
|
||||
<Box display={showHelp ? "none" : "flex"}>
|
||||
<Footer hints={footerHints(region, section, downloadFocus)} />
|
||||
<Footer hints={footerHints(region, section, downloadFocus, seedFocus)} />
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useStore, useQueueHistory, useSeeds, type SeedFocus } from "../store";
|
||||
import { Panel } from "./Panel";
|
||||
import { wrapStep, windowStart } from "../move";
|
||||
import { COLOR, GUTTER, ICON, SOURCE_STYLE } from "../theme";
|
||||
import { cleanText, formatBytes, formatBytesPerSec, truncate } from "../../util/format";
|
||||
import type { SeedItem } from "../../download/types";
|
||||
|
||||
const MARK = 2;
|
||||
const SIZE_W = 10;
|
||||
const STATUS_W = 14;
|
||||
const SRC_W = 4;
|
||||
const PAUSED = "#7c7785";
|
||||
|
||||
function glyph(seed: SeedItem | undefined): { icon: string; color: string } {
|
||||
if (!seed) return { icon: ICON.done, color: COLOR.good };
|
||||
if (seed.status === "seeding") return { icon: ICON.up, color: COLOR.good };
|
||||
if (seed.status === "paused") return { icon: ICON.pause, color: PAUSED };
|
||||
return { icon: ICON.warn, color: COLOR.warn };
|
||||
}
|
||||
|
||||
function statusCell(seed: SeedItem | undefined): { text: string; color?: string; dim: boolean } {
|
||||
if (!seed) return { text: "ready", dim: true };
|
||||
if (seed.status === "seeding") {
|
||||
return { text: `${ICON.up}${formatBytesPerSec(seed.uploadSpeed) || "0 B/s"} ${ICON.peer}${seed.peers}`, color: COLOR.good, dim: false };
|
||||
}
|
||||
if (seed.status === "paused") return { text: "paused", dim: true };
|
||||
return { text: "file gone", color: COLOR.warn, dim: false };
|
||||
}
|
||||
|
||||
export function Seeding() {
|
||||
const { queue, region, contentWidth, listRows, setNotice, setSeedFocus } = useStore();
|
||||
const history = useQueueHistory(queue);
|
||||
const seeds = useSeeds(queue);
|
||||
const focused = region === "content";
|
||||
|
||||
const total = history.length;
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const clamped = Math.min(cursor, Math.max(0, total - 1));
|
||||
|
||||
const focusStatus: SeedFocus | null =
|
||||
focused && total > 0 ? (seeds.get(history[clamped]?.id ?? "")?.status ?? "idle") : null;
|
||||
useEffect(() => {
|
||||
setSeedFocus(focusStatus);
|
||||
return () => setSeedFocus(null);
|
||||
}, [focusStatus, setSeedFocus]);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.upArrow) setCursor(wrapStep(clamped, -1, total));
|
||||
else if (key.downArrow) setCursor(wrapStep(clamped, 1, total));
|
||||
else if (input === "p") {
|
||||
const h = history[clamped];
|
||||
if (!h) return;
|
||||
queue.toggleSeeding(h);
|
||||
if (queue.getSeed(h.id)?.status === "missing") {
|
||||
setNotice(`${ICON.warn} That file isn't on disk anymore.`);
|
||||
}
|
||||
} else if (input === "c") {
|
||||
const h = history[clamped];
|
||||
if (h) queue.removeHistory(h.id);
|
||||
}
|
||||
},
|
||||
{ isActive: focused && total > 0 },
|
||||
);
|
||||
|
||||
const panelH = Math.max(5, listRows - 1);
|
||||
const seedingCount = queue.seedingCount;
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<Panel title="seeding" width={contentWidth} focused={focused} height={panelH}>
|
||||
<Text dimColor>Nothing here yet. Downloads start seeding automatically when they finish, and show up here.</Text>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary line: live totals across active seeds, or an invite to start.
|
||||
let totalUp = 0;
|
||||
let totalPeers = 0;
|
||||
let totalShared = 0;
|
||||
for (const s of seeds.values()) {
|
||||
totalShared += s.uploaded;
|
||||
if (s.status === "seeding") {
|
||||
totalUp += s.uploadSpeed;
|
||||
totalPeers += s.peers;
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Math.max(1, panelH - 2);
|
||||
const start = windowStart(clamped, total, rows);
|
||||
const visible = history.slice(start, start + rows);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="seeding"
|
||||
width={contentWidth}
|
||||
focused={focused}
|
||||
count={seedingCount > 0 ? `(${seedingCount})` : undefined}
|
||||
height={panelH}
|
||||
>
|
||||
<Box>
|
||||
{seedingCount > 0 ? (
|
||||
<Text color={COLOR.good}>
|
||||
{ICON.up} {formatBytesPerSec(totalUp) || "0 B/s"}
|
||||
<Text dimColor>{` ${ICON.dot} ${totalPeers} peers ${ICON.dot} ${formatBytes(totalShared)} shared back`}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>Downloads seed automatically when they finish. Press p to pause or resume any of them.</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Box width={MARK} flexShrink={0} />
|
||||
<Box width={GUTTER} flexShrink={0} />
|
||||
<Box flexGrow={1} minWidth={0} marginLeft={1}>
|
||||
<Text bold dimColor>Name</Text>
|
||||
</Box>
|
||||
<Box width={SIZE_W} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text bold dimColor>Size</Text>
|
||||
</Box>
|
||||
<Box width={STATUS_W} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text bold dimColor>Status</Text>
|
||||
</Box>
|
||||
<Box width={SRC_W} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text bold dimColor>Src</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{visible.map((h, i) => {
|
||||
const here = start + i === clamped && focused;
|
||||
const seed = seeds.get(h.id);
|
||||
const g = glyph(seed);
|
||||
const st = statusCell(seed);
|
||||
const ss = SOURCE_STYLE[h.source ?? "fitgirl"];
|
||||
return (
|
||||
<Box key={h.id}>
|
||||
<Box width={MARK} flexShrink={0}>
|
||||
<Text color={COLOR.accent} bold>{here ? ICON.pointer : ""}</Text>
|
||||
</Box>
|
||||
<Box width={GUTTER} flexShrink={0}>
|
||||
<Text color={g.color} dimColor={!seed && !here}>{g.icon}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0} marginLeft={1}>
|
||||
<Text wrap="truncate-end" bold={here} color={here ? COLOR.accent : undefined} dimColor={!here}>
|
||||
{cleanText(h.name)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={SIZE_W} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text dimColor>{h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-"}</Text>
|
||||
</Box>
|
||||
<Box width={STATUS_W} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text color={st.color} dimColor={st.dim}>{truncate(st.text, STATUS_W)}</Text>
|
||||
</Box>
|
||||
<Box width={SRC_W} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text color={h.source ? ss.color : undefined} dimColor={!h.source || !here}>
|
||||
{h.source ? ss.tag : "mag"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,12 @@ const FILTERS: NavItem[] = CATEGORIES.map((c) => ({
|
||||
key: c.key as Section,
|
||||
label: c.label,
|
||||
}));
|
||||
const LIBRARY: NavItem[] = [{ key: "downloads", label: "Downloads" }];
|
||||
const LIBRARY: NavItem[] = [
|
||||
{ key: "downloads", label: "Downloads" },
|
||||
{ key: "seeding", label: "Seeding" },
|
||||
];
|
||||
|
||||
const BADGED = (key: Section): boolean => key === "downloads" || key === "seeding";
|
||||
|
||||
const GROUPS: NavItem[][] = [FILTERS, LIBRARY];
|
||||
|
||||
@@ -21,8 +26,7 @@ const NAV: NavItem[] = GROUPS.flat();
|
||||
const BADGE_W = " (00)".length;
|
||||
|
||||
export const RAIL_WIDTH =
|
||||
GUTTER +
|
||||
Math.max(...NAV.map((n) => n.label.length + (n.key === "downloads" ? BADGE_W : 0)));
|
||||
GUTTER + Math.max(...NAV.map((n) => n.label.length + (BADGED(n.key) ? BADGE_W : 0)));
|
||||
|
||||
export function Sidebar() {
|
||||
const { section, setSection, region, setRegion, queue } = useStore();
|
||||
@@ -30,6 +34,7 @@ export function Sidebar() {
|
||||
const idx = Math.max(0, NAV.findIndex((n) => n.key === section));
|
||||
useQueueItems(queue);
|
||||
const active = queue.activeCount;
|
||||
const seeding = queue.seedingCount;
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
@@ -62,11 +67,14 @@ export function Sidebar() {
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.key === "downloads" && active > 0 ? (
|
||||
<Box flexShrink={0}>
|
||||
<Text dimColor>{` (${active})`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{(() => {
|
||||
const n = item.key === "downloads" ? active : item.key === "seeding" ? seeding : 0;
|
||||
return n > 0 ? (
|
||||
<Box flexShrink={0}>
|
||||
<Text dimColor>{` (${n})`}</Text>
|
||||
</Box>
|
||||
) : null;
|
||||
})()}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
+15
-2
@@ -1,4 +1,4 @@
|
||||
import type { DownloadFocus, Region, Section } from "./store";
|
||||
import type { DownloadFocus, Region, Section, SeedFocus } from "./store";
|
||||
|
||||
export interface Hint {
|
||||
keys: string;
|
||||
@@ -33,12 +33,19 @@ export const HELP_GROUPS: HelpGroup[] = [
|
||||
title: "Downloads",
|
||||
hints: [
|
||||
{ keys: "p", label: "Pause/resume" },
|
||||
{ keys: "c", label: "Cancel/remove" },
|
||||
{ keys: "c", label: "Cancel active, else remove" },
|
||||
{ keys: "f", label: "Retry failed" },
|
||||
{ keys: "d", label: "Download again" },
|
||||
{ keys: "x", label: "Clear recent" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Seeding",
|
||||
hints: [
|
||||
{ keys: "p", label: "Pause/resume" },
|
||||
{ keys: "c", label: "Remove (same as Downloads)" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const ALWAYS: Hint = { keys: "?", label: "Keys" };
|
||||
@@ -49,6 +56,7 @@ export function footerHints(
|
||||
region: Region,
|
||||
section: Section,
|
||||
downloadFocus?: DownloadFocus | null,
|
||||
seedFocus?: SeedFocus | null,
|
||||
): Hint[] {
|
||||
if (region === "sidebar") {
|
||||
return [
|
||||
@@ -59,6 +67,11 @@ export function footerHints(
|
||||
{ keys: "q", label: "Quit" },
|
||||
];
|
||||
}
|
||||
if (section === "seeding") {
|
||||
const label =
|
||||
seedFocus === "seeding" ? "Pause" : seedFocus === "missing" ? "Retry" : "Resume";
|
||||
return [{ keys: "p", label }, { keys: "c", label: "Remove" }, SWITCH, ALWAYS];
|
||||
}
|
||||
if (section === "downloads") {
|
||||
if (downloadFocus === "paused") {
|
||||
return [{ keys: "p", label: "Resume" }, { keys: "c", label: "Cancel" }, SWITCH, ALWAYS];
|
||||
|
||||
+29
-2
@@ -2,14 +2,14 @@ import { createContext, useContext, useEffect, useState } from "react";
|
||||
import type { Config } from "../config/config";
|
||||
import type { DownloadQueue } from "../download/queue";
|
||||
import type { HistoryItem } from "../download/history";
|
||||
import type { QueueItem } from "../download/types";
|
||||
import type { QueueItem, SeedItem } from "../download/types";
|
||||
import type { SourceGroup, SourceId } from "../sources/types";
|
||||
|
||||
export type View = "splash" | "browser";
|
||||
|
||||
export type Category = "all" | "games" | "movies" | "tv" | "anime";
|
||||
|
||||
export type Section = Category | "downloads";
|
||||
export type Section = Category | "downloads" | "seeding";
|
||||
|
||||
export const CATEGORIES: { key: Category; label: string; group?: SourceGroup }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
@@ -25,6 +25,8 @@ export type CaptureMode = "none" | "text" | "esc";
|
||||
|
||||
export type DownloadFocus = "downloading" | "paused" | "failed" | "recent";
|
||||
|
||||
export type SeedFocus = "seeding" | "paused" | "missing" | "idle";
|
||||
|
||||
export interface Store {
|
||||
config: Config;
|
||||
setConfig: (c: Config) => void;
|
||||
@@ -44,6 +46,8 @@ export interface Store {
|
||||
|
||||
downloadFocus: DownloadFocus | null;
|
||||
setDownloadFocus: (f: DownloadFocus | null) => void;
|
||||
seedFocus: SeedFocus | null;
|
||||
setSeedFocus: (f: SeedFocus | null) => void;
|
||||
|
||||
startDownload: (input: {
|
||||
id: string;
|
||||
@@ -114,3 +118,26 @@ export function useQueueHistory(queue: DownloadQueue): HistoryItem[] {
|
||||
}, [queue]);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useSeeds(queue: DownloadQueue): Map<string, SeedItem> {
|
||||
const [seeds, setSeeds] = useState<Map<string, SeedItem>>(
|
||||
() => new Map(queue.getSeeds().map((s) => [s.id, s])),
|
||||
);
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const onUpdate = (): void => {
|
||||
if (timer) return;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
setSeeds(new Map(queue.getSeeds().map((s) => [s.id, s])));
|
||||
}, 200);
|
||||
};
|
||||
queue.on("update", onUpdate);
|
||||
onUpdate();
|
||||
return () => {
|
||||
queue.off("update", onUpdate);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [queue]);
|
||||
return seeds;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const ICON = {
|
||||
warn: "⚠",
|
||||
bar: "▌",
|
||||
down: "↓",
|
||||
up: "↑",
|
||||
peer: "•",
|
||||
pause: "⏸",
|
||||
} as const;
|
||||
|
||||
Vendored
+11
@@ -10,6 +10,8 @@ declare module "webtorrent" {
|
||||
interface Torrent extends EventEmitter {
|
||||
infoHash: string;
|
||||
magnetURI: string;
|
||||
torrentFile: Uint8Array;
|
||||
ready: boolean;
|
||||
name: string;
|
||||
length: number;
|
||||
downloaded: number;
|
||||
@@ -25,6 +27,7 @@ declare module "webtorrent" {
|
||||
files: TorrentFile[];
|
||||
pause(): void;
|
||||
resume(): void;
|
||||
addPeer(peer: string): boolean;
|
||||
destroy(cb?: (err?: Error) => void): void;
|
||||
}
|
||||
|
||||
@@ -37,6 +40,8 @@ declare module "webtorrent" {
|
||||
maxConns?: number;
|
||||
dht?: boolean;
|
||||
utp?: boolean;
|
||||
tracker?: boolean;
|
||||
lsd?: boolean;
|
||||
}
|
||||
|
||||
class WebTorrent extends EventEmitter {
|
||||
@@ -44,11 +49,17 @@ declare module "webtorrent" {
|
||||
readonly torrents: Torrent[];
|
||||
readonly downloadSpeed: number;
|
||||
readonly uploadSpeed: number;
|
||||
readonly torrentPort: number;
|
||||
add(
|
||||
torrentId: string,
|
||||
opts?: TorrentOptions,
|
||||
cb?: (torrent: Torrent) => void,
|
||||
): Torrent;
|
||||
seed(
|
||||
input: string | string[],
|
||||
opts?: TorrentOptions,
|
||||
cb?: (torrent: Torrent) => void,
|
||||
): Torrent;
|
||||
get(torrentId: string): Torrent | null;
|
||||
remove(torrentId: string, cb?: (err?: Error) => void): void;
|
||||
destroy(cb?: (err?: Error) => void): void;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Keep tests off the real user data dir: redirect all persisted state (queue /
|
||||
// history / seeds / config) into a temp folder via the TORLINK_STATE_DIR
|
||||
// override that src/config/paths.ts honors. Applied before test modules import
|
||||
// paths.ts, so every write during a run lands here instead.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
env: {
|
||||
TORLINK_STATE_DIR: path.join(os.tmpdir(), "torlink-test-state"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user