diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx index a4d179dc..b798f84e 100644 --- a/scripts/render-previews-impl.tsx +++ b/scripts/render-previews-impl.tsx @@ -113,6 +113,9 @@ function makeStore( inspectingId: null, inspectingMagnet: null, setInspectingId: noop, + inspectingMetaId: null, + inspectingMetaMagnet: null, + setInspectingMetaId: noop, inspectFocusSelected: true, setInspectFocusSelected: noop, toggleFileSelection: noop, diff --git a/src/download/engine.ts b/src/download/engine.ts index 088f4f4d..d3418c92 100644 --- a/src/download/engine.ts +++ b/src/download/engine.ts @@ -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; @@ -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 { // Check if it's already active const existing = this.torrents.get(id); diff --git a/src/download/queue.metadata.test.ts b/src/download/queue.metadata.test.ts new file mode 100644 index 00000000..392138d8 --- /dev/null +++ b/src/download/queue.metadata.test.ts @@ -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(); + }); +}); diff --git a/src/download/queue.ts b/src/download/queue.ts index 3ea636fc..e9e9e937 100644 --- a/src/download/queue.ts +++ b/src/download/queue.ts @@ -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, @@ -507,6 +507,48 @@ export class DownloadQueue extends EventEmitter { return null; } + async getMetadata(id: string, magnet?: string): Promise { + 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); } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 52704cc6..0717e8a0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -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"; @@ -119,11 +120,17 @@ export function App({ const [inspectingId, setInspectingIdState] = useState(null); const [inspectingMagnet, setInspectingMagnet] = useState(null); const [inspectingPeersId, setInspectingPeersId] = useState(null); + const [inspectingMetaId, setInspectingMetaIdState] = useState(null); + const [inspectingMetaMagnet, setInspectingMetaMagnet] = useState(null); const [inspectFocusSelected, setInspectFocusSelected] = useState(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(null); const [recovered, setRecovered] = useState(false); @@ -537,6 +544,9 @@ export function App({ setInspectingId, inspectingPeersId, setInspectingPeersId, + inspectingMetaId, + inspectingMetaMagnet, + setInspectingMetaId, inspectFocusSelected, setInspectFocusSelected, toggleFileSelection, @@ -574,6 +584,8 @@ export function App({ inspectingId, inspectingMagnet, inspectingPeersId, + inspectingMetaId, + inspectingMetaMagnet, toggleFileSelection, toggleThrottle, listRows, @@ -815,6 +827,7 @@ export function App({ ) : null} + ); } diff --git a/src/ui/components/Downloads.tsx b/src/ui/components/Downloads.tsx index 0d058a0d..c126b319 100644 --- a/src/ui/components/Downloads.tsx +++ b/src/ui/components/Downloads.tsx @@ -60,6 +60,8 @@ export function Downloads() { setInspectingId, inspectingPeersId, setInspectingPeersId, + inspectingMetaId, + setInspectingMetaId, requestConfirm, } = useStore(); const active = useQueueItems(queue); @@ -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]; @@ -119,6 +122,7 @@ 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") { @@ -126,7 +130,7 @@ export function Downloads() { } } }, - { isActive: focused && total > 0 && !inspectingId && !inspectingPeersId }, + { isActive: focused && total > 0 && !inspectingId && !inspectingPeersId && !inspectingMetaId }, ); let focusKind: DownloadFocus | null = null; diff --git a/src/ui/components/MetadataInspector.tsx b/src/ui/components/MetadataInspector.tsx new file mode 100644 index 00000000..139871bd --- /dev/null +++ b/src/ui/components/MetadataInspector.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { useStore } from "../store"; +import { Panel } from "./Panel"; +import { COLOR, ICON, GUTTER } from "../theme"; +import { formatBytes, formatRelative, truncate } from "../../util/format"; +import type { ExtendedTorrentMeta } from "../../download/engine"; + +export function MetadataInspector() { + const { + queue, + inspectingMetaId, + setInspectingMetaId, + contentWidth, + rows, + } = useStore(); + + const [meta, setMeta] = useState(null); + + useEffect(() => { + if (!inspectingMetaId) { + setMeta(null); + return; + } + + // We only have observing interval here because metadata shouldn't change + // frequently, but if it comes from a magnet, it might load asynchronously. + let cancelled = false; + + // In search results we might not have a downloading torrent, but we do have the magnet. + // How do we know the magnet for the inspected ID? Wait, store doesn't have inspectingMagnet right now. + // But `queue.getMetadata(inspectingMetaId)` will fallback to checking if the .torrent exists or if it's active. + // If it's a raw magnet and not added, it won't be in the queue. + // We need to pass the magnet string. Let's get it from the store if possible, or just queue. + + // Wait, in `Results.tsx`, we have `result.magnet`. + // Let's add inspectingMetaMagnet to store, or just use what we have. + // Wait, let's fix that if needed. We'll use getMetadata(id) first. + + void queue.getMetadata(inspectingMetaId).then((m) => { + if (!cancelled) setMeta(m); + }); + + const timer = setInterval(() => { + void queue.getMetadata(inspectingMetaId).then((m) => { + if (!cancelled && m) setMeta(m); + }); + }, 1000); + + return () => { + cancelled = true; + clearInterval(timer); + }; + }, [queue, inspectingMetaId]); + + useInput( + (input, key) => { + if (key.escape || input === "w" || input === "v") { + setInspectingMetaId(null); + } + }, + { isActive: !!inspectingMetaId }, + ); + + if (!inspectingMetaId) return null; + + const panelH = Math.max(10, rows - 4); + const w = contentWidth; + const valW = w - 16 - GUTTER * 2; // 16 for labels + + return ( + + + + {!meta ? ( + Loading metadata... + ) : ( + <> + + Info Hash + {meta.infoHash} + + + + Total Size + {meta.length ? formatBytes(meta.length) : "-"} + + + + Created + + + {meta.created ? `${meta.created.toISOString().split('T')[0]} (${formatRelative(meta.created.getTime() / 1000)})` : "-"} + + + + + + Created By + {meta.createdBy || "-"} + + + + Comment + {meta.comment || "-"} + + + + Pieces + + + {meta.numPieces ? `${meta.numPieces} pieces` : "-"} + {meta.pieceLength ? ` @ ${formatBytes(meta.pieceLength)}` : ""} + + + + + + Trackers ({meta.announce.length}) + {meta.announce.length === 0 ? ( + No trackers found (DHT / PEX only) + ) : ( + meta.announce.slice(0, panelH - 12).map((tr, i) => ( + + {tr} + + )) + )} + {meta.announce.length > Math.max(0, panelH - 12) && ( + ... and {meta.announce.length - Math.max(0, panelH - 12)} more + )} + + + )} + + + + ); +} diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx index 13e2e0c2..249262d3 100644 --- a/src/ui/components/Results.tsx +++ b/src/ui/components/Results.tsx @@ -131,6 +131,7 @@ export function Results() { contentWidth, listRows, setInspectingId, + setInspectingMetaId, } = useStore(); const search = useConcurrentSearch(query); @@ -257,6 +258,9 @@ export function Results() { } else if (input === "y") { const r = results[clamped]; if (r) copyResultMagnet(r); + } else if (input === "v") { + const r = results[clamped]; + if (r) setInspectingMetaId(r.infoHash, r.magnet); } }, { isActive: focused && mode === "list" }, diff --git a/src/ui/components/Seeding.tsx b/src/ui/components/Seeding.tsx index 9938dae7..e4c7d348 100644 --- a/src/ui/components/Seeding.tsx +++ b/src/ui/components/Seeding.tsx @@ -31,7 +31,7 @@ function statusCell(seed: SeedItem | undefined): { text: string; color?: string; } export function Seeding() { - const { queue, region, contentWidth, listRows, openDownloadFolder, setSeedFocus, setInspectingId, setInspectingPeersId, inspectingId, inspectingPeersId, setNotice, requestConfirm } = + const { queue, region, contentWidth, listRows, openDownloadFolder, setSeedFocus, setInspectingId, setInspectingPeersId, setInspectingMetaId, inspectingId, inspectingPeersId, inspectingMetaId, setNotice, requestConfirm } = useStore(); const history = useQueueHistory(queue); const seeds = useSeeds(queue); @@ -79,9 +79,12 @@ export function Seeding() { } else if (input === "w") { const h = activeHistory[clamped]; if (h) setInspectingPeersId(h.id); + } else if (input === "v") { + const h = activeHistory[clamped]; + if (h) setInspectingMetaId(h.id); } }, - { isActive: focused && total > 0 && !inspectingId && !inspectingPeersId }, + { isActive: focused && total > 0 && !inspectingId && !inspectingPeersId && !inspectingMetaId }, ); const panelH = Math.max(5, listRows - 1); diff --git a/src/ui/keymap.ts b/src/ui/keymap.ts index e138f18c..be7f5bc2 100644 --- a/src/ui/keymap.ts +++ b/src/ui/keymap.ts @@ -162,6 +162,7 @@ export function footerHints( NAVIGATE, { keys: "d", label: "Download" }, { keys: "i", label: "Files" }, + { keys: "v", label: "Metadata" }, { keys: "y", label: "Copy" }, resultFocus === "detail" ? EXPORT : { keys: "s", label: "Sort" }, { keys: "/", label: "Search" }, diff --git a/src/ui/store.ts b/src/ui/store.ts index ac1a99cf..44ed06fd 100644 --- a/src/ui/store.ts +++ b/src/ui/store.ts @@ -90,6 +90,9 @@ export interface Store { setInspectingId: (id: string | null, magnet?: string) => void; inspectingPeersId: string | null; setInspectingPeersId: (id: string | null) => void; + inspectingMetaId: string | null; + inspectingMetaMagnet: string | null; + setInspectingMetaId: (id: string | null, magnet?: string) => void; inspectFocusSelected: boolean; setInspectFocusSelected: (s: boolean) => void; toggleFileSelection: (id: string, path: string, selected: boolean) => void; diff --git a/src/ui/testHarness.ts b/src/ui/testHarness.ts index e2a2102f..3443e8bf 100644 --- a/src/ui/testHarness.ts +++ b/src/ui/testHarness.ts @@ -177,6 +177,9 @@ export function makeTestStore(overrides: Partial = {}): Store { setInspectingId: noop, inspectingPeersId: null, setInspectingPeersId: noop, + inspectingMetaId: null, + inspectingMetaMagnet: null, + setInspectingMetaId: noop, inspectingMagnet: null, inspectFocusSelected: false, setInspectFocusSelected: noop,