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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ node_modules/
dist/
.DS_Store
package-lock.json
bun.lock
.bun/
164 changes: 82 additions & 82 deletions preview/browse.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
224 changes: 112 additions & 112 deletions preview/downloads.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
90 changes: 45 additions & 45 deletions preview/splash.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions scripts/render-previews-impl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,5 @@ save(
</Box>,
{ shimmer: true },
);


60 changes: 56 additions & 4 deletions src/download/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
});
});
Expand All @@ -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<number>): 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<number>): 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,
Expand Down
41 changes: 41 additions & 0 deletions src/download/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
72 changes: 65 additions & 7 deletions src/download/queue.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -47,7 +49,7 @@ export interface AddInput {

export class DownloadQueue extends EventEmitter {
private items = new Map<string, QueueItem>();
private engine = new TorrentEngine();
public readonly engine = new TorrentEngine();
private poll: ReturnType<typeof setInterval> | null = null;
private history: HistoryItem[] = [];
private seeds = new Map<string, SeedItem>();
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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<number>(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();
},
Expand Down Expand Up @@ -173,9 +192,28 @@ export class DownloadQueue extends EventEmitter {
};
}

private async cleanUnwanted(it: QueueItem): Promise<void> {
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);
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Loading