Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions scripts/render-previews-impl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ function makeStore(
setResultFocus: noop,
startDownload: noop,
requestDownloadTo: noop,
requestFileSelection: noop,
requestReselect: noop,
copyMagnet: noop,
openDownloadFolder: noop,
exportTorrent: noop,
Expand Down
158 changes: 158 additions & 0 deletions src/download/engine.exclude.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { EventEmitter } from "node:events";
import { describe, it, expect, vi, afterEach } from "vitest";
import type { TorrentMeta } from "./engine";

const addOpts: Record<string, unknown>[] = [];

vi.mock("webtorrent", () => {
return {
default: class extends EventEmitter {
torrentPort = 6881;
add(_source: string, opts?: Record<string, unknown>): EventEmitter {
addOpts.push(opts ?? {});
return new EventEmitter();
}
destroy(): void {}
},
};
});

afterEach(() => {
addOpts.length = 0;
vi.resetModules();
});

// A fake TorrentFile that records whether it was selected or deselected.
function fakeFile(length: number) {
return {
length,
selected: false,
deselected: false,
select() {
this.selected = true;
},
deselect() {
this.deselected = true;
},
};
}

async function makeEngine() {
const { TorrentEngine } = await import("./engine");
return new TorrentEngine();
}

// Reach into the private torrents map to drive the mock torrent's metadata.
function grabTorrent(engine: unknown, id: string): EventEmitter & Record<string, unknown> {
const map = (engine as { torrents: Map<string, unknown> }).torrents;
return map.get(id) as EventEmitter & Record<string, unknown>;
}

const MAGNET = "magnet:?xt=urn:btih:0000000000000000000000000000000000000000";

describe("TorrentEngine file exclusion", () => {
it("adds with deselect:true and selects only the kept files", async () => {
const engine = await makeEngine();
let meta: TorrentMeta | undefined;
engine.add("id1", MAGNET, "/dl", { onMetadata: (m) => (meta = m) }, undefined, [1]);

// deselect option must be set so nothing downloads until we select.
expect(addOpts[0]).toMatchObject({ deselect: true });

const t = grabTorrent(engine, "id1");
const files = [fakeFile(100), fakeFile(200), fakeFile(300)] as const;
t.files = files;
t.length = 600;
t.name = "pack";
t.emit("metadata");

expect(files[0].selected).toBe(true);
expect(files[0].deselected).toBe(false);
expect(files[1].deselected).toBe(true); // excluded index 1
expect(files[1].selected).toBe(false);
expect(files[2].selected).toBe(true);
// Reported total is the selected bytes, not the whole torrent.
expect(meta?.total).toBe(400);
engine.destroy();
});

it("never sets deselect when nothing is excluded", async () => {
const engine = await makeEngine();
engine.add("id2", MAGNET, "/dl", {}, undefined, []);
expect(addOpts[0]).not.toHaveProperty("deselect");
engine.destroy();
});

it("reselect() flips selection on a running torrent and returns selected bytes", async () => {
const engine = await makeEngine();
engine.add("id4", MAGNET, "/dl", {}, undefined, []); // added with everything selected

const t = grabTorrent(engine, "id4");
const files = [fakeFile(100), fakeFile(200), fakeFile(300)] as const;
t.files = files;
t.length = 600;

// Now exclude file 0: it deselects all, then re-selects the kept ones.
const selected = engine.reselect("id4", [0]);
expect(selected).toBe(500);
expect(files[0].selected).toBe(false);
expect(files[1].selected).toBe(true);
expect(files[2].selected).toBe(true);
// Kept files must never be deselected — emptying selections would drop peer
// interest and stall the download, which reads as a restart.
expect(files[0].deselected).toBe(true);
expect(files[1].deselected).toBe(false);
expect(files[2].deselected).toBe(false);
// stats now rescale against the 500 kept bytes.
t.downloaded = 250;
t.progress = 250 / 600;
expect(engine.stats("id4")?.total).toBe(500);
expect(engine.stats("id4")?.progress).toBeCloseTo(0.5, 5);
engine.destroy();
});

it("reselect() with no exclusions clears the rescale and reports full length", async () => {
const engine = await makeEngine();
engine.add("id5", MAGNET, "/dl", {}, undefined, [1]);
const t = grabTorrent(engine, "id5");
t.files = [fakeFile(100), fakeFile(200)] as const;
t.length = 300;
t.emit("metadata");
expect(engine.stats("id5")?.total).toBe(100); // only file 0 selected

const full = engine.reselect("id5", []);
expect(full).toBe(300);
// No exclusions → stats fall back to webtorrent's whole-torrent length.
t.downloaded = 150;
t.progress = 0.5;
expect(engine.stats("id5")?.total).toBe(300);
engine.destroy();
});

it("reselect() returns null when the torrent isn't present", async () => {
const engine = await makeEngine();
expect(engine.reselect("nope", [0])).toBeNull();
engine.destroy();
});

it("rescales stats progress/total against the selected bytes", async () => {
const engine = await makeEngine();
engine.add("id3", MAGNET, "/dl", {}, undefined, [0]);

const t = grabTorrent(engine, "id3");
const files = [fakeFile(100), fakeFile(300)];
t.files = files;
t.length = 400; // whole torrent
t.name = "pack";
// 150 of the 300 selected bytes downloaded (50% of what we're fetching).
t.downloaded = 150;
t.progress = 150 / 400; // webtorrent's whole-torrent ratio (37.5%)
t.downloadSpeed = 150;
t.emit("metadata");

const s = engine.stats("id3");
expect(s?.total).toBe(300);
expect(s?.progress).toBeCloseTo(0.5, 5);
engine.destroy();
});
});
84 changes: 82 additions & 2 deletions src/download/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export function message(e: unknown): string {
export class TorrentEngine {
private client: WebTorrent | null = null;
private torrents = new Map<string, Torrent>();
// For torrents with excluded files: the total bytes of the *selected* files.
// webtorrent's own progress/length always count the whole torrent, so a
// partial download would otherwise stall below 100%. Set once metadata
// arrives; stats() rescales progress/eta against it.
private selectedBytes = new Map<string, number>();

private ensureClient(): WebTorrent {
if (!this.client) {
Expand All @@ -57,12 +62,16 @@ export class TorrentEngine {
// locally instead of re-fetching metadata from the swarm.
// `announce` supplements whatever trackers are already in the source URI;
// webtorrent dedupes internally.
// `exclude` lists file indices to skip. When set, the torrent is added with
// nothing selected and only the kept files are re-selected once metadata
// arrives, so excluded files never touch disk.
add(
id: string,
source: string,
dir: string,
handlers: AddHandlers,
announce?: string[],
exclude?: number[],
): void {
const client = this.ensureClient();
const existing = this.torrents.get(id);
Expand All @@ -72,8 +81,16 @@ export class TorrentEngine {
existing.destroy();
} catch {}
}
this.selectedBytes.delete(id);

const opts = announce && announce.length > 0 ? { path: dir, announce } : { path: dir };
const excluding = !!exclude && exclude.length > 0;
const opts = {
path: dir,
...(announce && announce.length > 0 ? { announce } : {}),
// With no files selected, webtorrent downloads nothing until we select the
// ones to keep in the metadata handler below.
...(excluding ? { deselect: true } : {}),
};
let torrent: Torrent;
try {
torrent = client.add(source, opts);
Expand All @@ -84,9 +101,24 @@ export class TorrentEngine {
this.torrents.set(id, torrent);

torrent.on("metadata", () => {
let total = torrent.length;
if (excluding) {
const skip = new Set(exclude);
let selected = 0;
(torrent.files ?? []).forEach((f, i) => {
if (skip.has(i)) {
f.deselect();
} else {
f.select();
selected += f.length;
}
});
this.selectedBytes.set(id, selected);
total = selected;
}
handlers.onMetadata?.({
name: torrent.name,
total: torrent.length,
total,
files: torrent.files?.length ?? 0,
torrentFile: torrent.torrentFile,
});
Expand All @@ -105,6 +137,40 @@ export class TorrentEngine {
});
}

// Change which files a *running* torrent downloads, in place. Deselects only
// the excluded files, then re-asserts the kept ones — so the kept selections
// stay live the whole time and the torrent never goes idle (emptying every
// selection would drop peer interest and stall/re-ramp the download, looking
// like a restart). The two passes must run in this order: deselecting an
// excluded file also drops any piece it shares on a boundary with a kept file,
// and the second pass re-selects that piece back. Already-downloaded data
// stays on disk. Returns the selected byte total (whole torrent when nothing
// is excluded), or null when the torrent isn't present or has no metadata yet.
reselect(id: string, exclude: number[]): number | null {
const t = this.torrents.get(id);
const files = t?.files;
if (!t || !files || files.length === 0) return null;
const skip = new Set(exclude);
// Pass 1: drop the excluded files.
files.forEach((f, i) => {
if (skip.has(i)) f.deselect();
});
// Pass 2: re-assert the kept files (restores any shared boundary pieces).
let selected = 0;
files.forEach((f, i) => {
if (!skip.has(i)) {
f.select();
selected += f.length;
}
});
if (skip.size > 0) {
this.selectedBytes.set(id, selected);
return selected;
}
this.selectedBytes.delete(id);
return t.length;
}

// The TCP port the client accepts incoming peers on (diagnostics / tests).
listenPort(): number | null {
return this.client?.torrentPort ?? null;
Expand Down Expand Up @@ -134,6 +200,18 @@ export class TorrentEngine {
peers = t.numPeers || 0;
timeRemaining = typeof t.timeRemaining === "number" && !isNaN(t.timeRemaining) ? t.timeRemaining : Infinity;
name = t.name || "";

// Excluded-file downloads: rescale against the selected bytes so progress
// and eta track only what we're actually fetching, not the whole torrent.
// webtorrent may still pull a few shared bytes of a deselected piece, so
// clamp to avoid overshooting 100%.
const selected = this.selectedBytes.get(id);
if (selected !== undefined && selected > 0) {
total = selected;
progress = Math.min(1, downloaded / selected);
const remaining = selected - downloaded;
timeRemaining = speed > 0 && remaining > 0 ? (remaining / speed) * 1000 : 0;
}
} catch {
// Every stat is read inside this try on purpose: webtorrent getters can
// throw before metadata parses and on a torrent in an error state, and
Expand All @@ -157,6 +235,7 @@ export class TorrentEngine {
remove(id: string): void {
const t = this.torrents.get(id);
this.torrents.delete(id);
this.selectedBytes.delete(id);
if (t) {
try {
t.destroy();
Expand All @@ -166,6 +245,7 @@ export class TorrentEngine {

destroy(): void {
this.torrents.clear();
this.selectedBytes.clear();
// 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;
Expand Down
3 changes: 3 additions & 0 deletions src/download/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export interface HistoryItem {
magnet: string;
dir: string;
completedAt: number;
// Carried from the download so a later re-seed from the .torrent re-applies
// the same exclusions instead of trying to fetch the skipped files.
excludedFiles?: number[];
}

const write = serializeWrites();
Expand Down
8 changes: 8 additions & 0 deletions src/download/persist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ export async function saveTorrentMeta(id: string, data: Uint8Array): Promise<voi
} catch {}
}

export async function loadTorrentMeta(id: string): Promise<Uint8Array | null> {
try {
return await fs.readFile(torrentMetaPath(id));
} catch {
return null;
}
}

export async function exportTorrentMeta(id: string, name: string, dir: string): Promise<string | null> {
try {
const source = torrentMetaPath(id);
Expand Down
Loading