Skip to content
Merged
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
3 changes: 3 additions & 0 deletions scripts/render-previews-impl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ function makeStore(
inspectingId: null,
inspectingMagnet: null,
setInspectingId: noop,
inspectingMetaId: null,
inspectingMetaMagnet: null,
setInspectingMetaId: noop,
inspectFocusSelected: true,
setInspectFocusSelected: noop,
toggleFileSelection: noop,
Expand Down
36 changes: 36 additions & 0 deletions src/download/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ export interface TorrentMeta {
torrentFile?: Uint8Array;
}

export interface ExtendedTorrentMeta {
infoHash: string;
name: string;
announce: string[];
created?: Date;
createdBy?: string;
comment?: string;
pieceLength?: number;
numPieces?: number;
length?: number;
}

export interface AddHandlers {
onMetadata?: (meta: TorrentMeta) => void;
onDone?: () => void;
Expand Down Expand Up @@ -215,6 +227,30 @@ export class TorrentEngine {
}));
}

getMetadata(id: string): ExtendedTorrentMeta | null {
const t = this.torrents.get(id);
if (!t) return null;
// Webtorrent's Torrent interface might not expose all of these explicitly in DT,
// so we cast to any for properties not in the types.
const anyT = t as any;

// Webtorrent exposes the parsed torrent file internally as `t.parsedTorrent` or similar,
// but sometimes attributes are at the top level.
const announce: string[] = Array.isArray(anyT.announce) ? anyT.announce : [];

return {
infoHash: t.infoHash || id,
name: t.name || "",
announce,
created: anyT.created,
createdBy: anyT.createdBy,
comment: anyT.comment,
pieceLength: anyT.pieceLength,
numPieces: Array.isArray(anyT.pieces) ? anyT.pieces.length : undefined,
length: t.length || 0,
};
}

async fetchMetadata(id: string, magnet: string): Promise<TorrentFileInfo[]> {
// Check if it's already active
const existing = this.torrents.get(id);
Expand Down
109 changes: 109 additions & 0 deletions src/download/queue.metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { DownloadQueue } from "./queue";
import * as persist from "./persist";
import { promises as fs } from "node:fs";
import parseTorrent from "parse-torrent";

vi.mock("./engine", () => {
const mockEngine = {
getMetadata: vi.fn(),
add: vi.fn(),
remove: vi.fn(),
};
return {
TorrentEngine: vi.fn().mockImplementation(function() { return mockEngine; }),
message: vi.fn((e) => String(e)),
};
});

vi.mock("./persist", () => ({
torrentMetaExists: vi.fn(),
torrentMetaPath: vi.fn(),
}));

vi.mock("node:fs", () => ({
promises: {
readFile: vi.fn(),
},
existsSync: vi.fn(),
mkdirSync: vi.fn(),
renameSync: vi.fn(),
}));

vi.mock("parse-torrent", () => ({
default: vi.fn(),
}));

describe("DownloadQueue getMetadata", () => {
let queue: DownloadQueue;
let engineMock: any;

beforeEach(() => {
vi.clearAllMocks();
queue = new DownloadQueue();
engineMock = (queue as any).engine;
});

it("returns live metadata from engine if available", async () => {
const fakeMeta = { infoHash: "abc", name: "test", announce: [] };
engineMock.getMetadata.mockReturnValue(fakeMeta);

const result = await queue.getMetadata("abc");
expect(engineMock.getMetadata).toHaveBeenCalledWith("abc");
expect(result).toBe(fakeMeta);
});

it("parses .torrent file from disk if engine has no live meta", async () => {
engineMock.getMetadata.mockReturnValue(null);
vi.mocked(persist.torrentMetaExists).mockReturnValue(true);
vi.mocked(persist.torrentMetaPath).mockReturnValue("/tmp/test.torrent");

const fakeBuf = Buffer.from("test");
vi.mocked(fs.readFile).mockResolvedValue(fakeBuf);

vi.mocked(parseTorrent).mockReturnValue({
infoHash: "abc",
name: "parsed name",
announce: ["http://tracker.org"],
length: 123,
} as any);

const result = await queue.getMetadata("abc");
expect(result).toMatchObject({
infoHash: "abc",
name: "parsed name",
announce: ["http://tracker.org"],
length: 123,
});
expect(fs.readFile).toHaveBeenCalledWith("/tmp/test.torrent");
});

it("parses magnet URI if not in engine and no .torrent file", async () => {
engineMock.getMetadata.mockReturnValue(null);
vi.mocked(persist.torrentMetaExists).mockReturnValue(false);

vi.mocked(parseTorrent).mockReturnValue({
infoHash: "def",
name: "magnet name",
announce: ["udp://tracker2.org"],
} as any);

const magnet = "magnet:?xt=urn:btih:def&dn=magnet+name";
const result = await queue.getMetadata("def", magnet);

expect(parseTorrent).toHaveBeenCalledWith(magnet);
expect(result).toMatchObject({
infoHash: "def",
name: "magnet name",
announce: ["udp://tracker2.org"],
});
});

it("returns null if no sources have metadata", async () => {
engineMock.getMetadata.mockReturnValue(null);
vi.mocked(persist.torrentMetaExists).mockReturnValue(false);

const result = await queue.getMetadata("xyz");
expect(result).toBeNull();
});
});
44 changes: 43 additions & 1 deletion src/download/queue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EventEmitter } from "node:events";
import { TorrentEngine, message, type AddHandlers } from "./engine";
import { TorrentEngine, message, type AddHandlers, type ExtendedTorrentMeta } from "./engine";
import {
saveQueue,
saveQueueSync,
Expand Down Expand Up @@ -507,6 +507,48 @@ export class DownloadQueue extends EventEmitter {
return null;
}

async getMetadata(id: string, magnet?: string): Promise<ExtendedTorrentMeta | null> {
const live = this.engine.getMetadata(id);
if (live) return live;

if (torrentMetaExists(id)) {
try {
const buf = await fs.readFile(torrentMetaPath(id));
const parsed = await parseTorrent(buf) as any;
return {
infoHash: parsed.infoHash || id,
name: parsed.name || "",
announce: Array.isArray(parsed.announce) ? parsed.announce : [],
created: parsed.created,
createdBy: parsed.createdBy,
comment: parsed.comment,
pieceLength: parsed.pieceLength,
numPieces: Array.isArray(parsed.pieces) ? parsed.pieces.length : undefined,
length: parsed.length || 0,
};
} catch {}
}

if (magnet) {
try {
const parsed = await parseTorrent(magnet) as any;
return {
infoHash: parsed.infoHash || id,
name: parsed.name || "",
announce: Array.isArray(parsed.announce) ? parsed.announce : [],
created: parsed.created,
createdBy: parsed.createdBy,
comment: parsed.comment,
pieceLength: parsed.pieceLength,
numPieces: Array.isArray(parsed.pieces) ? parsed.pieces.length : undefined,
length: parsed.length || 0,
};
} catch {}
}

return null;
}

getFiles(id: string): TorrentFileInfo[] | null {
return this.engine.getFiles(id);
}
Expand Down
13 changes: 13 additions & 0 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import { Logo } from "./components/Logo";
import { Sidebar, RAIL_WIDTH } from "./components/Sidebar";
import { PeerInspector } from "./components/PeerInspector";
import { MetadataInspector } from "./components/MetadataInspector";
import { Rule } from "./components/Rule";
import { Footer } from "./components/Footer";
import { HelpOverlay } from "./components/HelpOverlay";
Expand Down Expand Up @@ -119,11 +120,17 @@ export function App({
const [inspectingId, setInspectingIdState] = useState<string | null>(null);
const [inspectingMagnet, setInspectingMagnet] = useState<string | null>(null);
const [inspectingPeersId, setInspectingPeersId] = useState<string | null>(null);
const [inspectingMetaId, setInspectingMetaIdState] = useState<string | null>(null);
const [inspectingMetaMagnet, setInspectingMetaMagnet] = useState<string | null>(null);
const [inspectFocusSelected, setInspectFocusSelected] = useState<boolean>(true);
const setInspectingId = useCallback((id: string | null, magnet?: string) => {
setInspectingIdState(id);
setInspectingMagnet(magnet ?? null);
}, []);
const setInspectingMetaId = useCallback((id: string | null, magnet?: string) => {
setInspectingMetaIdState(id);
setInspectingMetaMagnet(magnet ?? null);
}, []);

const [updateVersion, setUpdateVersion] = useState<string | null>(null);
const [recovered, setRecovered] = useState(false);
Expand Down Expand Up @@ -537,6 +544,9 @@ export function App({
setInspectingId,
inspectingPeersId,
setInspectingPeersId,
inspectingMetaId,
inspectingMetaMagnet,
setInspectingMetaId,
inspectFocusSelected,
setInspectFocusSelected,
toggleFileSelection,
Expand Down Expand Up @@ -574,6 +584,8 @@ export function App({
inspectingId,
inspectingMagnet,
inspectingPeersId,
inspectingMetaId,
inspectingMetaMagnet,
toggleFileSelection,
toggleThrottle,
listRows,
Expand Down Expand Up @@ -815,6 +827,7 @@ export function App({
</Box>
) : null}
</Box>
<MetadataInspector />
</StoreContext.Provider>
);
}
6 changes: 5 additions & 1 deletion src/ui/components/Downloads.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export function Downloads() {
setInspectingId,
inspectingPeersId,
setInspectingPeersId,
inspectingMetaId,
setInspectingMetaId,
requestConfirm,
} = useStore();
const active = useQueueItems(queue);
Expand Down Expand Up @@ -103,6 +105,7 @@ export function Downloads() {
}
else if (input === "p") queue.togglePause(it.id);
else if (input === "w") setInspectingPeersId(it.id);
else if (input === "v") setInspectingMetaId(it.id, it.magnet);
else if (input === "i" || input === "Enter" || input === " ") setInspectingId(it.id);
} else {
const h = recent[recentCursor];
Expand All @@ -119,14 +122,15 @@ export function Downloads() {
requestConfirm(`Remove and delete '${truncate(cleanText(h.name), 40)}'?`, () => queue.removeHistory(h.id));
}
else if (input === "i") setInspectingId(h.id);
else if (input === "v") setInspectingMetaId(h.id, h.magnet);
// Clear-all lives here, not at the top of the chain, so it can only
// fire while the cursor is actually on the recent list.
else if (input === "C" || input === "x") {
requestConfirm("Clear recent downloads history? Files will be deleted.", () => queue.clearHistory());
}
}
},
{ isActive: focused && total > 0 && !inspectingId && !inspectingPeersId },
{ isActive: focused && total > 0 && !inspectingId && !inspectingPeersId && !inspectingMetaId },
);

let focusKind: DownloadFocus | null = null;
Expand Down
Loading