diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx index 39aa8d1d..7f14dda9 100644 --- a/scripts/render-previews-impl.tsx +++ b/scripts/render-previews-impl.tsx @@ -100,6 +100,8 @@ function makeStore( setResultFocus: noop, startDownload: noop, requestDownloadTo: noop, + requestFileSelection: noop, + requestReselect: noop, copyMagnet: noop, openDownloadFolder: noop, exportTorrent: noop, diff --git a/src/download/engine.exclude.test.ts b/src/download/engine.exclude.test.ts new file mode 100644 index 00000000..94566d77 --- /dev/null +++ b/src/download/engine.exclude.test.ts @@ -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[] = []; + +vi.mock("webtorrent", () => { + return { + default: class extends EventEmitter { + torrentPort = 6881; + add(_source: string, opts?: Record): 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 { + const map = (engine as { torrents: Map }).torrents; + return map.get(id) as EventEmitter & Record; +} + +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(); + }); +}); diff --git a/src/download/engine.ts b/src/download/engine.ts index 17868c5d..0956ea56 100644 --- a/src/download/engine.ts +++ b/src/download/engine.ts @@ -35,6 +35,11 @@ export function message(e: unknown): string { export class TorrentEngine { private client: WebTorrent | null = null; private torrents = new Map(); + // 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(); private ensureClient(): WebTorrent { if (!this.client) { @@ -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); @@ -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); @@ -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, }); @@ -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; @@ -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 @@ -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(); @@ -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; diff --git a/src/download/history.ts b/src/download/history.ts index e71e72cf..ecda1cbd 100644 --- a/src/download/history.ts +++ b/src/download/history.ts @@ -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(); diff --git a/src/download/persist.ts b/src/download/persist.ts index 537a38a9..9a870818 100644 --- a/src/download/persist.ts +++ b/src/download/persist.ts @@ -92,6 +92,14 @@ export async function saveTorrentMeta(id: string, data: Uint8Array): Promise { + try { + return await fs.readFile(torrentMetaPath(id)); + } catch { + return null; + } +} + export async function exportTorrentMeta(id: string, name: string, dir: string): Promise { try { const source = torrentMetaPath(id); diff --git a/src/download/queue.exclude.test.ts b/src/download/queue.exclude.test.ts new file mode 100644 index 00000000..d8a8ad90 --- /dev/null +++ b/src/download/queue.exclude.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { DownloadQueue } from "./queue"; + +// Capture what the queue hands the engine so we can assert the exclusion list +// is forwarded (add's 6th argument). +const addCalls: unknown[][] = []; +const reselectCalls: unknown[][] = []; +let reselectReturn: number | null = 400; + +vi.mock("./engine", () => ({ + message: (e: unknown) => (e instanceof Error ? e.message : String(e)), + TorrentEngine: class { + add(...args: unknown[]): void { + addCalls.push(args); + } + reselect(...args: unknown[]): number | null { + reselectCalls.push(args); + return reselectReturn; + } + remove(): void {} + stats(): undefined { + return undefined; + } + destroy(): void {} + }, +})); + +beforeEach(() => { + addCalls.length = 0; + reselectCalls.length = 0; + reselectReturn = 400; +}); + +const base = { + id: "t1", + name: "Season Pack", + magnet: "magnet:?xt=urn:btih:0000000000000000000000000000000000000000", +}; + +describe("DownloadQueue file exclusion", () => { + it("stores excludedFiles on the item and forwards them to the engine", () => { + const q = new DownloadQueue(); + q.add({ ...base, excludedFiles: [1, 3] }, "/dl"); + const it = q.getItems()[0]!; + expect(it.excludedFiles).toEqual([1, 3]); + // engine.add(id, magnet, dir, handlers, trackers, exclude) + expect(addCalls[0]![5]).toEqual([1, 3]); + q.suspend(); + }); + + it("normalizes an empty exclusion list to undefined (download everything)", () => { + const q = new DownloadQueue(); + q.add({ ...base, excludedFiles: [] }, "/dl"); + const it = q.getItems()[0]!; + expect(it.excludedFiles).toBeUndefined(); + expect(addCalls[0]![5]).toBeUndefined(); + q.suspend(); + }); + + it("re-applies the persisted exclusions when a paused item resumes", () => { + const q = new DownloadQueue(); + q.add({ ...base, excludedFiles: [2] }, "/dl"); + q.pause("t1"); + addCalls.length = 0; + q.resume("t1"); + expect(addCalls[0]![5]).toEqual([2]); + q.suspend(); + }); + + it("reselect() on a live download applies to the engine and updates totalBytes", () => { + const q = new DownloadQueue(); + q.add(base, "/dl"); // starts downloading, no exclusions + reselectReturn = 400; + const ok = q.reselect("t1", [1]); + expect(ok).toBe(true); + expect(reselectCalls[0]).toEqual(["t1", [1]]); + const it = q.getItems()[0]!; + expect(it.excludedFiles).toEqual([1]); + expect(it.totalBytes).toBe(400); + q.suspend(); + }); + + it("reselect() on a paused download stores exclusions without touching the engine", () => { + const q = new DownloadQueue(); + q.add(base, "/dl"); + q.pause("t1"); + reselectCalls.length = 0; + const ok = q.reselect("t1", [2]); + expect(ok).toBe(true); + expect(reselectCalls).toHaveLength(0); // no live torrent to touch + expect(q.getItems()[0]!.excludedFiles).toEqual([2]); + q.suspend(); + }); + + it("reselect() returns false for an unknown id", () => { + const q = new DownloadQueue(); + expect(q.reselect("ghost", [0])).toBe(false); + q.suspend(); + }); + + it("forwards a history item's exclusions when it is re-seeded", () => { + const q = new DownloadQueue(); + q.restoreHistory([ + { + id: "t1", + name: base.name, + sizeBytes: 100, + magnet: base.magnet, + dir: "/dl", + completedAt: 1, + excludedFiles: [4], + }, + ]); + q.startSeeding(q.getHistory()[0]!); + expect(addCalls[0]![5]).toEqual([4]); + q.suspend(); + }); +}); diff --git a/src/download/queue.ts b/src/download/queue.ts index 21c990eb..0f886320 100644 --- a/src/download/queue.ts +++ b/src/download/queue.ts @@ -6,6 +6,7 @@ import { saveSeeds, saveSeedsSync, saveTorrentMeta, + loadTorrentMeta, torrentMetaPath, torrentMetaExists, exportTorrentMeta, @@ -15,7 +16,8 @@ import { import { saveHistory, saveHistorySync, type HistoryItem } from "./history"; import { deleteSeedData } from "./delete-data"; import { disarmBootMarker } from "./bootguard"; -import type { QueueItem, SeedItem } from "./types"; +import parseTorrent from "parse-torrent"; +import type { QueueItem, SeedItem, TorrentFileEntry } from "./types"; import type { SourceId } from "../sources/types"; /** @@ -58,6 +60,9 @@ export interface AddInput { magnet: string; source?: SourceId; sizeBytes?: number; + // File indices to skip (see the exclude-before-download picker). Omitted or + // empty downloads every file. + excludedFiles?: number[]; } export interface RestoreOptions { @@ -116,6 +121,10 @@ export class DownloadQueue extends EventEmitter { } const existing = this.items.get(input.id); if (existing && existing.status !== "failed") return; + // Empty selections mean "download everything" — store nothing so persisted + // items stay clean and the engine takes its normal all-files path. + const excludedFiles = + input.excludedFiles && input.excludedFiles.length > 0 ? input.excludedFiles : undefined; const item: QueueItem = existing ? { ...existing, @@ -126,6 +135,8 @@ export class DownloadQueue extends EventEmitter { status: "downloading", error: undefined, speed: 0, + // A re-add is a fresh request, so it also adopts the new exclusion set. + excludedFiles, ...(existing.dir === dir ? {} : { progress: 0, downloadedBytes: 0, eta: undefined }), @@ -142,6 +153,7 @@ export class DownloadQueue extends EventEmitter { downloadedBytes: 0, speed: 0, peers: 0, + excludedFiles, addedAt: Date.now(), }; // Respect the concurrent-download cap: start now if a slot is free, else @@ -159,7 +171,14 @@ export class DownloadQueue extends EventEmitter { private startEngine(item: QueueItem): void { try { - this.engine.add(item.id, item.magnet, item.dir, this.engineHandlers(item.id), this.trackers); + this.engine.add( + item.id, + item.magnet, + item.dir, + this.engineHandlers(item.id), + this.trackers, + item.excludedFiles, + ); } catch (e) { // engine.add routes webtorrent's own synchronous failures through // onError, so the only throw that reaches here is the client failing to @@ -410,34 +429,32 @@ export class DownloadQueue extends EventEmitter { return exportTorrentMeta(it.id, it.name, it.dir); } - // Fetch the .torrent metadata for a magnet-only result (e.g. a search hit - // that has never been downloaded) and export it to exportDir. If the metadata - // is already cached from a prior download it is exported immediately without - // touching the network. The torrent handle is destroyed as soon as metadata - // arrives — no file content is downloaded. - fetchAndExportTorrent( - input: { id: string; name: string; magnet: string }, - exportDir: string, - ): Promise { + // Ensure the .torrent metadata for a magnet is cached on disk, fetching it + // from the swarm if needed. The torrent handle is destroyed as soon as + // metadata arrives — no file content is downloaded. Resolves true when the + // metadata is available afterwards, false when it couldn't be obtained. + // `dir` is only where webtorrent would place files; nothing is written there. + private ensureMeta( + input: { id: string; magnet: string }, + dir: string, + ): Promise { // Fast path: cached from a previous download. - if (torrentMetaExists(input.id)) { - return exportTorrentMeta(input.id, input.name, exportDir); - } + if (torrentMetaExists(input.id)) return Promise.resolve(true); // If this torrent is already in the engine (downloading / seeding), its // metadata will arrive through the normal queue flow; don't double-add it. if (this.items.has(input.id) || this.seeds.has(input.id)) { - return Promise.resolve(null); + return Promise.resolve(false); } - return new Promise((resolve) => { + return new Promise((resolve) => { const tempKey = `__meta__${input.id}`; let done = false; const timer = setTimeout(() => { if (done) return; done = true; this.engine.remove(tempKey); - resolve(null); + resolve(false); }, FETCH_METADATA_TIMEOUT_MS); - this.engine.add(tempKey, input.magnet, exportDir, { + this.engine.add(tempKey, input.magnet, dir, { onMetadata: (meta) => { if (done) return; done = true; @@ -446,7 +463,7 @@ export class DownloadQueue extends EventEmitter { this.engine.remove(tempKey); void (async () => { if (meta.torrentFile) await saveTorrentMeta(input.id, meta.torrentFile); - resolve(await exportTorrentMeta(input.id, input.name, exportDir)); + resolve(torrentMetaExists(input.id)); })(); }, onError: () => { @@ -454,12 +471,74 @@ export class DownloadQueue extends EventEmitter { done = true; clearTimeout(timer); this.engine.remove(tempKey); - resolve(null); + resolve(false); }, }); }); } + // Fetch the .torrent metadata for a magnet-only result (e.g. a search hit + // that has never been downloaded) and export it to exportDir. If the metadata + // is already cached from a prior download it is exported immediately without + // touching the network. + async fetchAndExportTorrent( + input: { id: string; name: string; magnet: string }, + exportDir: string, + ): Promise { + if (!(await this.ensureMeta(input, exportDir))) return null; + return exportTorrentMeta(input.id, input.name, exportDir); + } + + // Resolve the file list for a magnet so the user can choose what to exclude + // before downloading. Uses cached metadata when present, otherwise fetches it + // from the swarm (no file content downloaded). Returns null if it can't be + // read. `dir` is only a placeholder path for the metadata fetch. + async fetchFiles( + input: { id: string; magnet: string }, + dir: string, + ): Promise { + if (!(await this.ensureMeta(input, dir))) return null; + const buf = await loadTorrentMeta(input.id); + if (!buf) return null; + try { + const parsed = await parseTorrent(buf); + const files = parsed.files; + if (!files || files.length === 0) return null; + // Prefer the full relative path so nested files stay distinguishable; + // index order matches webtorrent's torrent.files, which the exclusion + // relies on. + return files.map((f, index) => ({ index, name: f.path || f.name, length: f.length })); + } catch { + return null; + } + } + + // Change a queued/active download's file selection after it has started. For a + // live download the change is applied to the running torrent immediately (no + // restart); for a paused or queued item it's stored and applied when it next + // starts. Returns false if the id isn't a current download. + reselect(id: string, exclude: number[]): boolean { + const it = this.items.get(id); + if (!it) return false; + it.excludedFiles = exclude.length > 0 ? exclude : undefined; + if (it.status === "downloading") { + const selected = this.engine.reselect(id, exclude); + if (selected !== null && selected > 0) { + it.totalBytes = selected; + // The selected set changed, so progress against it did too; pull the + // engine's fresh view instead of showing a stale percentage. + const s = this.engine.stats(id); + if (s) { + it.progress = Math.min(100, Math.round(s.progress * 100)); + it.downloadedBytes = s.downloaded; + } + } + } + this.changed(); + void this.persist(); + return true; + } + cancel(id: string): void { if (!this.items.has(id)) return; this.engine.remove(id); @@ -577,7 +656,7 @@ export class DownloadQueue extends EventEmitter { // file immediately, no swarm needed); fall back to the magnet otherwise. const source = torrentMetaExists(h.id) ? torrentMetaPath(h.id) : h.magnet; try { - this.engine.add(h.id, source, h.dir, this.engineHandlers(h.id), this.trackers); + this.engine.add(h.id, source, h.dir, this.engineHandlers(h.id), this.trackers, h.excludedFiles); } catch { // Same narrow case as startEngine: only a client that won't construct // lands here. Leave the seed paused so it stays visible and resumable. @@ -706,6 +785,7 @@ export class DownloadQueue extends EventEmitter { magnet: it.magnet, dir: it.dir, completedAt: Date.now(), + excludedFiles: it.excludedFiles, }; this.history = [rec, ...this.history.filter((h) => h.id !== it.id)].slice(0, HISTORY_MAX); void saveHistory(this.history).catch(() => {}); diff --git a/src/download/types.ts b/src/download/types.ts index 5b4639e9..e05ce646 100644 --- a/src/download/types.ts +++ b/src/download/types.ts @@ -20,6 +20,13 @@ export interface SeedItem { peers: number; } +// One file inside a torrent, as offered to the exclude-before-download picker. +export interface TorrentFileEntry { + index: number; + name: string; + length: number; +} + export interface QueueItem { id: string; name: string; @@ -34,6 +41,9 @@ export interface QueueItem { peers: number; eta?: number; files?: number; + // File indices the user chose to skip before starting. Persisted so a resume + // or retry re-applies the same exclusions when the engine re-adds the torrent. + excludedFiles?: number[]; error?: string; addedAt: number; } diff --git a/src/parse-torrent.d.ts b/src/parse-torrent.d.ts index c7a11259..bbbf204f 100644 --- a/src/parse-torrent.d.ts +++ b/src/parse-torrent.d.ts @@ -1,8 +1,16 @@ declare module "parse-torrent" { + interface ParsedTorrentFile { + path: string; + name: string; + length: number; + offset: number; + } interface ParsedTorrent { infoHash: string; name?: string; announce?: string[]; + length?: number; + files?: ParsedTorrentFile[]; } export default function parseTorrent( torrentId: Uint8Array | string, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 441dd469..f86ea15b 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -44,12 +44,22 @@ import { TabTitle } from "./components/TabTitle"; import { Splash } from "./views/Splash"; import { FolderPrompt } from "./components/FolderPrompt"; import { TrackersPrompt } from "./components/TrackersPrompt"; +import { FilePicker } from "./components/FilePicker"; import { footerHints } from "./keymap"; import { COLOR, ICON } from "./theme"; import { useMouseWheel } from "./hooks/useMouseWheel"; import { VERSION } from "../version"; import { fetchLatestVersion, isNewer } from "../update/version"; import type { SourceId } from "../sources/types"; +import type { TorrentFileEntry } from "../download/types"; + +interface DownloadRequest { + id: string; + name: string; + magnet: string; + source?: SourceId; + sizeBytes?: number; +} export function App({ initialMagnet, @@ -109,6 +119,23 @@ export function App({ sizeBytes?: number; } | null>(null); const [lastDownloadToDir, setLastDownloadToDir] = useState(null); + // The file picker's pending target, with its resolved file list; null when + // closed. "start" is a not-yet-queued download; "reselect" is an existing + // download whose selection is being changed. fileFetchSeq drops a slow + // file-list fetch whose result arrives after a newer request superseded it. + const [pendingFilePick, setPendingFilePick] = useState< + | { kind: "start"; input: DownloadRequest; files: TorrentFileEntry[]; initialExcluded: number[] } + | { + kind: "reselect"; + id: string; + name: string; + sizeBytes?: number; + files: TorrentFileEntry[]; + initialExcluded: number[]; + } + | null + >(null); + const fileFetchSeq = useRef(0); const [notice, setNotice] = useState(null); const [updateVersion, setUpdateVersion] = useState(null); const [recovered, setRecovered] = useState(false); @@ -332,6 +359,108 @@ export function App({ [queue, pendingDownload], ); + const requestFileSelection = useCallback( + (input: DownloadRequest) => { + if (!config || !queue) return; + const seq = ++fileFetchSeq.current; + setNotice("Reading file list…"); + void (async () => { + const files = await queue.fetchFiles( + { id: input.id, magnet: input.magnet }, + config.downloadDir, + ); + if (seq !== fileFetchSeq.current) return; // superseded by a newer pick + if (!files) { + // Can't know what to exclude, so honour the intent to download and say so. + startDownload(input); + setNotice(`Couldn't read files; downloading all of ${truncate(cleanText(input.name), 28)}.`); + return; + } + if (files.length <= 1) { + startDownload(input); + setNotice(`Only one file — downloading ${truncate(cleanText(input.name), 32)}.`); + return; + } + setNotice(null); + setPendingFilePick({ kind: "start", input, files, initialExcluded: [] }); + })(); + }, + [config, queue, startDownload], + ); + + // Re-open the picker for a download already in the queue. Its .torrent is + // cached from the download, so the file list resolves without touching the + // network. Preloads the item's current exclusions. + const requestReselect = useCallback( + (id: string) => { + if (!config || !queue) return; + const it = queue.getItems().find((i) => i.id === id); + if (!it) return; + const seq = ++fileFetchSeq.current; + setNotice("Reading file list…"); + void (async () => { + const files = await queue.fetchFiles({ id: it.id, magnet: it.magnet }, config.downloadDir); + if (seq !== fileFetchSeq.current) return; // superseded + if (!files) { + setNotice(`File list not ready for ${truncate(cleanText(it.name), 32)}.`); + return; + } + if (files.length <= 1) { + setNotice(`${truncate(cleanText(it.name), 32)} has a single file — nothing to change.`); + return; + } + setNotice(null); + setPendingFilePick({ + kind: "reselect", + id: it.id, + name: it.name, + sizeBytes: it.totalBytes, + files, + initialExcluded: it.excludedFiles ?? [], + }); + })(); + }, + [config, queue], + ); + + const closeFilePick = useCallback(() => { + // Any in-flight fetch is now stale; bump the seq so its result is dropped. + fileFetchSeq.current++; + setPendingFilePick(null); + }, []); + + const confirmFilePick = useCallback( + (excluded: number[]) => { + const pick = pendingFilePick; + setPendingFilePick(null); + if (!pick || !config || !queue) return; + if (pick.kind === "reselect") { + queue.reselect(pick.id, excluded); + setNotice( + excluded.length === 0 + ? `Now downloading all files of ${truncate(cleanText(pick.name), 28)}.` + : `Updated ${truncate(cleanText(pick.name), 28)} (skipping ${excluded.length} file${excluded.length === 1 ? "" : "s"})`, + ); + setSection("downloads"); + setRegion("content"); + return; + } + const input = pick.input; + if (excluded.length === 0) { + startDownload(input); + return; + } + void fs.mkdir(config.downloadDir, { recursive: true }).catch(() => {}); + queue.add({ ...input, excludedFiles: excluded }, config.downloadDir); + setNotice( + `Added: ${truncate(cleanText(input.name), 32)} (skipped ${excluded.length} file${excluded.length === 1 ? "" : "s"})`, + ); + setSection("downloads"); + setRegion("content"); + }, + [pendingFilePick, config, queue, startDownload], + ); + const copyMagnet = useCallback((input: { name: string; magnet: string }) => { void (async () => { const ok = await writeClipboard(input.magnet); @@ -486,7 +615,10 @@ export function App({ submitQuery, section, setSection, - region: showHelp || editingFolder || editingTrackers || pendingDownload ? "help" : region, + region: + showHelp || editingFolder || editingTrackers || pendingDownload || pendingFilePick + ? "help" + : region, setRegion, captureMode, setCaptureMode, @@ -498,6 +630,8 @@ export function App({ setResultFocus, startDownload, requestDownloadTo, + requestFileSelection, + requestReselect, copyMagnet, openDownloadFolder, exportTorrent, @@ -523,12 +657,15 @@ export function App({ editingFolder, editingTrackers, pendingDownload, + pendingFilePick, captureMode, downloadFocus, seedFocus, resultFocus, startDownload, requestDownloadTo, + requestFileSelection, + requestReselect, copyMagnet, openDownloadFolder, exportTorrent, @@ -549,7 +686,7 @@ export function App({ quitAll(); return; } - if (editingFolder || editingTrackers || pendingDownload) return; // the prompt owns input (its own esc + enter) + if (editingFolder || editingTrackers || pendingDownload || pendingFilePick) return; // the prompt owns input (its own esc + enter) if (captureMode === "text") return; if (showHelp) { setShowHelp(false); @@ -685,10 +822,39 @@ export function App({ ) : null} + {pendingFilePick + ? (() => { + const fp = pendingFilePick; + const name = fp.kind === "start" ? fp.input.name : fp.name; + const sizeBytes = fp.kind === "start" ? fp.input.sizeBytes : fp.sizeBytes; + return ( + + + + ); + })() + : null} + @@ -704,7 +870,13 @@ export function App({ {showFooter ? ( - +