diff --git a/.gitignore b/.gitignore index c073aad..7e80a4d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ dist/ .DS_Store package-lock.json +bun.lock +.bun/ \ No newline at end of file diff --git a/preview/browse.svg b/preview/browse.svg index 951a1c8..40057b4 100644 --- a/preview/browse.svg +++ b/preview/browse.svg @@ -4,122 +4,122 @@ torlink - ๐“ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - All - โ•ญโ”€ - Search - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ + ๐“ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + All + โ•ญโ”€ + Search + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ Games - - โฏ + + โฏ Search or paste a magnet linkโ€ฆ - + Movies - โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ TV Anime - โ•ญโ”€ - Latest (5) - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ - + โ•ญโ”€ + Latest (5) + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ + newest across all sources - + Downloads - - + + Seeding - + # Name Size Seed:Lch Src - - - โฏ + + + โฏ 1 - Oppenheimer (2023) [1080pโ€ฆ + Oppenheimer (2023) [1080pโ€ฆ 1.96 GB - 1240:88 - YTS - - + 1240:88 + YTS + + 2 Dune: Part Two (2024) [21โ€ฆ 7.82 GB - 910:41 - YTS - - + 910:41 + YTS + + 3 Breaking Bad S05E14 1080pโ€ฆ 1.49 GB - 540:31 - EZTV - - + 540:31 + EZTV + + 4 [Erai-raws] Jujutsu Kaiseโ€ฆ 1.21 GB - 320:12 - NYAA - - + 320:12 + NYAA + + 5 Frieren - 28 [1080p] 1.30 GB - - SUB - - โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ - โ†‘โ†“โ†โ†’ + SUB + + โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + โ†‘โ†“โ†โ†’ Move - d + d Download - y + y Copy - s + s Sort - / + / Search - tab + tab Switch - ? + ? Keys \ No newline at end of file diff --git a/preview/downloads.svg b/preview/downloads.svg index 7c8c339..d4d933d 100644 --- a/preview/downloads.svg +++ b/preview/downloads.svg @@ -4,151 +4,151 @@ torlink - ๐“ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ๐“ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ All - โ•ญโ”€ - Downloads (1) - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ + โ•ญโ”€ + Downloads (1) + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ Games - - โฏ - โ†“ - Dune: Part Two (2024) [2160p BluRay] + + โฏ + โ†“ + Dune: Part Two (2024) [2160p BluRay] 7.82 GB - YTS - + YTS + Movies - - - - - - - - - - - - - - - - โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ + + + + + + + + + + + + + + + + โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘ 64% 7.7 MB/s โ€ข41 6m - + TV - - + + Anime - + Recently downloaded (2) - - - โœ“ + + + โœ“ Elden Ring: Shadow of tโ€ฆ 50.29 GB 1hr ago - FG - - - Downloads + FG + + + Downloads (1) - - โœ“ + + โœ“ Breaking Bad S05E14 108โ€ฆ 1.49 GB 1d 1hr ago - EZTV - + EZTV + Seeding - - - - - โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ - p + + + + + โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + p Pause - c + c Cancel - tab + tab Switch - ? + ? Keys - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + \ No newline at end of file diff --git a/preview/splash.svg b/preview/splash.svg index bd904ab..f705b35 100644 --- a/preview/splash.svg +++ b/preview/splash.svg @@ -4,57 +4,57 @@ torlink - ๐“ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - A curated, terminal-native torrent downloader. + ๐“ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A curated, terminal-native torrent downloader. games ยท movies ยท tv ยท anime - โ•ญโ”€ - Search - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ - - โฏ + โ•ญโ”€ + Search + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ + + โฏ S earch or paste a magnet linkโ€ฆ - - โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ - โ†ต + + โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + โ†ต search ยท empty - โ†ต + โ†ต browse ยท - ^c + ^c quit \ No newline at end of file diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx index d440196..f4ba5c6 100644 --- a/scripts/render-previews-impl.tsx +++ b/scripts/render-previews-impl.tsx @@ -260,3 +260,5 @@ save( , { shimmer: true }, ); + + diff --git a/src/download/engine.ts b/src/download/engine.ts index a454e52..f0c80bb 100644 --- a/src/download/engine.ts +++ b/src/download/engine.ts @@ -12,10 +12,16 @@ export interface TorrentProgress { name: string; } +export interface TorrentFileMeta { + path: string; + length: number; +} + export interface TorrentMeta { name: string; total: number; files: number; + fileList: TorrentFileMeta[]; // 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). @@ -80,6 +86,7 @@ export class TorrentEngine { name: torrent.name, total: torrent.length, files: torrent.files?.length ?? 0, + fileList: (torrent.files ?? []).map((f) => ({ path: f.path, length: f.length })), torrentFile: torrent.torrentFile, }); }); @@ -102,13 +109,58 @@ export class TorrentEngine { return this.client?.torrentPort ?? null; } - stats(id: string): TorrentProgress | null { + getTorrent(id: string): Torrent | undefined { + return this.torrents.get(id); + } + + fileStats(id: string): { path: string; length: number; progress: number }[] | null { + const t = this.torrents.get(id); + if (!t?.files) return null; + return t.files.map((f) => ({ path: f.path, length: f.length, progress: f.progress })); + } + + applyFileSelection(id: string, selectedIndices: Set): void { + const t = this.torrents.get(id); + if (!t?.files) return; + for (let i = 0; i < t.files.length; i++) { + const file = t.files[i]; + if (file) { + if (selectedIndices.has(i)) { + file.select(); + } else { + file.deselect(); + } + } + } + } + + stats(id: string, selectedIndices?: Set): TorrentProgress | null { const t = this.torrents.get(id); if (!t) return null; + + let total = t.length; + let downloaded = t.downloaded; + let progress = t.progress; + + if (selectedIndices && t.files) { + total = 0; + downloaded = 0; + for (let i = 0; i < t.files.length; i++) { + if (selectedIndices.has(i)) { + const f = t.files[i]; + if (f) { + total += f.length; + downloaded += Math.round(f.progress * f.length); + } + } + } + progress = total > 0 ? downloaded / total : 0; + } + return { - progress: t.progress, - downloaded: t.downloaded, - total: t.length, + progress, + downloaded, + total, speed: t.downloadSpeed, uploadSpeed: t.uploadSpeed, uploaded: t.uploaded, diff --git a/src/download/queue.test.ts b/src/download/queue.test.ts index f3d46d3..bb26f86 100644 --- a/src/download/queue.test.ts +++ b/src/download/queue.test.ts @@ -61,3 +61,44 @@ describe("strayDownload (missing-file safety-net)", () => { expect(strayDownload({ total: 0, progress: 0, speed: 0 })).toBe(false); }); }); + +describe("DownloadQueue selective download", () => { + it("add with selectedIndices stores indices and processes selective stats on metadata", () => { + const q = new DownloadQueue(); + q.add( + { + id: "sel1", + name: "Selective Item", + magnet: "magnet:?xt=urn:btih:1111111111111111111111111111111111111111", + selectedIndices: [0], + }, + "/downloads", + ); + const items = q.getItems(); + const item = items.find((i) => i.id === "sel1"); + expect(item).toBeDefined(); + expect(item?.status).toBe("downloading"); + expect(item?.selectedIndices).toEqual([0]); + expect(q.activeCount).toBe(1); + + // Trigger metadata handler mock call + const handlers = q["engineHandlers"]("sel1"); + handlers.onMetadata?.({ + name: "Selective Item", + total: 300, + files: 2, + fileList: [ + { path: "f1.txt", length: 100 }, + { path: "f2.txt", length: 200 }, + ], + torrentFile: new Uint8Array(), + }); + + const updated = q.getItems().find((i) => i.id === "sel1"); + expect(updated?.totalBytes).toBe(100); + expect(updated?.fileList?.[0]?.selected).toBe(true); + expect(updated?.fileList?.[1]?.selected).toBe(false); + + q.suspend(); + }); +}); diff --git a/src/download/queue.ts b/src/download/queue.ts index 5d02d02..5123147 100644 --- a/src/download/queue.ts +++ b/src/download/queue.ts @@ -1,4 +1,6 @@ import { EventEmitter } from "node:events"; +import { promises as fs, existsSync } from "node:fs"; +import path from "node:path"; import { TorrentEngine, type AddHandlers } from "./engine"; import { saveQueue, @@ -47,7 +49,7 @@ export interface AddInput { export class DownloadQueue extends EventEmitter { private items = new Map(); - private engine = new TorrentEngine(); + public readonly engine = new TorrentEngine(); private poll: ReturnType | null = null; private history: HistoryItem[] = []; private seeds = new Map(); @@ -76,7 +78,7 @@ export class DownloadQueue extends EventEmitter { return this.items.has(id); } - add(input: AddInput, dir: string): void { + add(input: AddInput & { selectedIndices?: number[] }, dir: string): void { if (this.seeds.has(input.id)) { this.engine.remove(input.id); this.seeds.delete(input.id); @@ -87,7 +89,7 @@ export class DownloadQueue extends EventEmitter { const existing = this.items.get(input.id); if (existing && existing.status !== "failed") return; const item: QueueItem = existing - ? { ...existing, status: "downloading", error: undefined, speed: 0 } + ? { ...existing, status: "downloading", error: undefined, speed: 0, selectedIndices: input.selectedIndices } : { id: input.id, name: input.name, @@ -100,6 +102,7 @@ export class DownloadQueue extends EventEmitter { downloadedBytes: 0, speed: 0, peers: 0, + selectedIndices: input.selectedIndices, addedAt: Date.now(), }; this.items.set(item.id, item); @@ -127,8 +130,24 @@ export class DownloadQueue extends EventEmitter { 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; + if (meta.fileList) { + it.fileList = meta.fileList.map((f, i) => ({ + path: f.path, + length: f.length, + progress: 0, + selected: it.selectedIndices ? it.selectedIndices.includes(i) : true, + })); + } + if (it.selectedIndices) { + this.engine.applyFileSelection(id, new Set(it.selectedIndices)); + it.totalBytes = it.selectedIndices.reduce( + (sum, i) => sum + (meta.fileList?.[i]?.length ?? 0), + 0 + ); + } else { + if (meta.total) it.totalBytes = meta.total; + } this.changed(); void this.persist(); }, @@ -173,9 +192,28 @@ export class DownloadQueue extends EventEmitter { }; } + private async cleanUnwanted(it: QueueItem): Promise { + if (!it.fileList || !it.selectedIndices) return; + const selected = new Set(it.selectedIndices); + for (let i = 0; i < it.fileList.length; i++) { + if (selected.has(i)) continue; + const file = it.fileList[i]; + if (!file) continue; + const src = path.join(it.dir, file.path); + if (!existsSync(src)) continue; + const unwantedDir = path.join(it.dir, path.dirname(file.path), ".unwanted"); + try { + await fs.mkdir(unwantedDir, { recursive: true }); + const dest = path.join(unwantedDir, path.basename(file.path)); + await fs.rename(src, dest); + } catch {} + } + } + private complete(it: QueueItem): void { this.recordHistory(it); this.items.delete(it.id); + void this.cleanUnwanted(it); // 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); @@ -210,9 +248,10 @@ export class DownloadQueue extends EventEmitter { private tick(): void { let any = false; - for (const it of this.items.values()) { + for (const it of Array.from(this.items.values())) { if (it.status !== "downloading") continue; - const s = this.engine.stats(it.id); + const sel = it.selectedIndices ? new Set(it.selectedIndices) : undefined; + const s = this.engine.stats(it.id, sel); if (!s) continue; it.progress = Math.min(100, Math.round(s.progress * 100)); it.downloadedBytes = s.downloaded; @@ -224,6 +263,23 @@ export class DownloadQueue extends EventEmitter { ? s.timeRemaining / 1000 : undefined; if (s.name) it.name = s.name; + + const fs = this.engine.fileStats(it.id); + if (fs && it.fileList) { + for (let i = 0; i < it.fileList.length; i++) { + const file = it.fileList[i]; + const fileStat = fs[i]; + if (file && fileStat) { + file.progress = fileStat.progress; + } + } + } + + if (sel && it.progress >= 100) { + if (it.totalBytes) it.downloadedBytes = it.totalBytes; + this.complete(it); + } + any = true; } const now = Date.now(); @@ -455,7 +511,9 @@ export class DownloadQueue extends EventEmitter { restore(items: QueueItem[]): void { for (const raw of items) { this.items.set(raw.id, raw); - if (raw.status === "downloading") this.startEngine(raw); + if (raw.status === "downloading") { + this.startEngine(raw); + } } if (this.activeCount > 0) this.ensurePoll(); this.changed(); diff --git a/src/download/types.ts b/src/download/types.ts index 4a8e664..80bff16 100644 --- a/src/download/types.ts +++ b/src/download/types.ts @@ -4,6 +4,13 @@ export type DownloadStatus = "downloading" | "paused" | "completed" | "failed"; export type SeedStatus = "seeding" | "paused" | "missing"; +export interface TorrentFileInfo { + path: string; + length: number; + progress: number; + selected: boolean; +} + export interface SeedItem { id: string; name: string; @@ -31,6 +38,8 @@ export interface QueueItem { peers: number; eta?: number; files?: number; + fileList?: TorrentFileInfo[]; + selectedIndices?: number[]; error?: string; addedAt: number; } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index eb1e14e..15b7e03 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -214,6 +214,7 @@ export function App({ magnet: string; source?: SourceId; sizeBytes?: number; + selectedIndices?: number[]; }) => { if (!config || !queue) return; void fs.mkdir(config.downloadDir, { recursive: true }).catch(() => {}); diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx index 7e13349..8545d71 100644 --- a/src/ui/components/Results.tsx +++ b/src/ui/components/Results.tsx @@ -28,9 +28,121 @@ function DetailRow({ label, value }: { label: string; value: ReactNode }) { ); } -function Detail({ r, width }: { r: TorrentResult; width: number }) { +interface DetailProps { + r: TorrentResult; + width: number; + listHeight: number; + focused: boolean; + onClose: () => void; +} + +function Detail({ r, width, listHeight, focused, onClose }: DetailProps) { + const { queue, config, startDownload, copyMagnet } = useStore(); const ss = SOURCE_STYLE[r.source]; const date = formatRelative(r.added); + + const [fileList, setFileList] = useState<{ path: string; length: number }[] | null>(null); + const [loading, setLoading] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [cursor, setCursor] = useState(0); + const [error, setError] = useState(null); + + useEffect(() => { + // If already in queue, check if it has fileList + const qItem = queue.getItems().find((it) => it.id === r.infoHash); + if (qItem && qItem.fileList) { + setFileList(qItem.fileList.map((f) => ({ path: f.path, length: f.length }))); + setSelected(new Set(qItem.selectedIndices ?? qItem.fileList.map((_, i) => i))); + return; + } + + if (r.numFiles && r.numFiles <= 1) { + return; + } + + setLoading(true); + setError(null); + + let active = true; + const tempId = `temp-${r.infoHash}`; + + try { + queue.engine.add( + tempId, + r.magnet, + config.downloadDir, + { + onMetadata: (meta) => { + if (!active) return; + if (meta.fileList) { + setFileList(meta.fileList); + setSelected(new Set(meta.fileList.map((_, i) => i))); + } + setLoading(false); + queue.engine.applyFileSelection(tempId, new Set()); + }, + onError: (msg) => { + if (!active) return; + setError(msg); + setLoading(false); + }, + }, + config.trackers, + ); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setLoading(false); + } + + return () => { + active = false; + queue.engine.remove(tempId); + }; + }, [r.infoHash, queue, config, r.magnet, r.numFiles]); + + const showFileList = fileList && fileList.length > 1; + + useInput( + (input, key) => { + if (key.escape) { + onClose(); + } else if (input === "y") { + copyMagnet({ name: r.name, magnet: r.magnet }); + } else if (input === "d") { + startDownload({ + id: r.infoHash, + name: r.name, + magnet: r.magnet, + source: r.source, + sizeBytes: r.sizeBytes, + selectedIndices: showFileList ? Array.from(selected) : undefined, + }); + onClose(); + } else if (showFileList && fileList) { + if (key.upArrow || input === "k") { + setCursor((c) => Math.max(0, c - 1)); + } else if (key.downArrow || input === "j") { + setCursor((c) => Math.min(fileList.length - 1, c + 1)); + } else if (input === " ") { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(cursor)) next.delete(cursor); + else next.add(cursor); + return next; + }); + } else if (input === "a") { + setSelected((prev) => { + if (prev.size === fileList.length) return new Set(); + return new Set(fileList.map((_, i) => i)); + }); + } else if (input === "n") { + setSelected(new Set()); + } + } + }, + { isActive: focused }, + ); + const health = r.seeders || r.leechers ? ( @@ -42,6 +154,11 @@ function Detail({ r, width }: { r: TorrentResult; width: number }) { ) : ( unknown ); + + const fileBoxHeight = Math.max(3, listHeight - 11); + const fileStart = windowStart(cursor, fileList?.length ?? 0, fileBoxHeight); + const visibleFiles = fileList ? fileList.slice(fileStart, fileStart + fileBoxHeight) : []; + return ( @@ -90,6 +207,73 @@ function Detail({ r, width }: { r: TorrentResult; width: number }) { } /> + + {loading && ( + + + + )} + + {error && ( + + Failed to load files: {error} + + )} + + {showFileList && fileList && ( + + + Files selection checklist: + + + {visibleFiles.map((file, i) => { + const globalIdx = fileStart + i; + const isFocused = globalIdx === cursor; + const isChecked = selected.has(globalIdx); + + const displayPath = file.path.includes("/") + ? file.path.substring(file.path.indexOf("/") + 1) + : file.path; + + return ( + + + {isFocused ? ICON.pointer : ""} + + + + {isChecked ? "[x]" : "[ ]"} + + + + + {truncate(cleanText(displayPath), width - 30)} + + + + {formatBytes(file.length)} + + + ); + })} + + + + [space] toggle . [a] all . [n] none + + + Selected: {selected.size} / {fileList.length} files ({formatBytes(Array.from(selected).reduce((sum, idx) => sum + (fileList[idx]?.length ?? 0), 0))}) + + + + )} + d @@ -205,16 +389,7 @@ export function Results() { { isActive: focused && mode === "list" }, ); - useInput( - (input, key) => { - if (key.escape) { - setMode("list"); - setDetail(null); - } else if (input === "d" && detail) openDownload(detail); - else if (input === "y" && detail) copyResultMagnet(detail); - }, - { isActive: focused && mode === "detail" }, - ); + useInput( (_input, key) => { @@ -327,7 +502,16 @@ export function Results() { height={panelOuter} > {mode === "detail" && detail ? ( - + { + setMode("list"); + setDetail(null); + }} + /> ) : ( <> {status()} diff --git a/src/ui/keymap.ts b/src/ui/keymap.ts index 325d599..da2d787 100644 --- a/src/ui/keymap.ts +++ b/src/ui/keymap.ts @@ -31,6 +31,9 @@ export const HELP_GROUPS: HelpGroup[] = [ { keys: "s", label: "Sort results" }, { keys: "y", label: "Copy magnet" }, { keys: "m", label: "Paste magnet" }, + { keys: "space", label: "Toggle file selection" }, + { keys: "a", label: "Select all files" }, + { keys: "n", label: "Deselect all files" }, ], }, { diff --git a/src/ui/store.ts b/src/ui/store.ts index 0a6f4ab..d2f705e 100644 --- a/src/ui/store.ts +++ b/src/ui/store.ts @@ -55,6 +55,7 @@ export interface Store { magnet: string; source?: SourceId; sizeBytes?: number; + selectedIndices?: number[]; }) => void; copyMagnet: (input: { name: string; magnet: string }) => void; diff --git a/src/webtorrent.d.ts b/src/webtorrent.d.ts index 568e3e9..030feaf 100644 --- a/src/webtorrent.d.ts +++ b/src/webtorrent.d.ts @@ -5,6 +5,10 @@ declare module "webtorrent" { name: string; path: string; length: number; + offset: number; + progress: number; + select(priority?: number): void; + deselect(): void; } interface Torrent extends EventEmitter {