From cdb2f994a58c491542af438f169aee9c995d1ef1 Mon Sep 17 00:00:00 2001 From: walter <108946347+walterz-eth@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:46:27 -0300 Subject: [PATCH 1/3] Add getTorrent function to Store interface --- src/ui/store.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/store.ts b/src/ui/store.ts index b96eeaf5..a6af4e85 100644 --- a/src/ui/store.ts +++ b/src/ui/store.ts @@ -77,6 +77,9 @@ export interface Store { // Fetches the .torrent metadata for a search result (via magnet if not yet // cached) and exports it to the configured download folder. fetchAndExportTorrent: (input: { id: string; name: string; magnet: string }) => void; + // Fetches the .torrent metadata for a search result (via magnet if not yet + // cached) and exports it to the configured download folder without downloading. + getTorrent: (input: { id: string; name: string; magnet: string }) => void; notice: string | null; setNotice: (s: string | null) => void; From 0708c1428ae6678caa146296563e3c3b7d1aa779 Mon Sep 17 00:00:00 2001 From: walter Date: Tue, 25 Aug 2026 15:04:24 -0300 Subject: [PATCH 2/3] feat: add torrent-only export functionality and update keymap hints --- src/ui/App.tsx | 2 +- src/ui/components/Results.test.tsx | 21 +++++++++++++++++++++ src/ui/components/Results.tsx | 10 ++++++++++ src/ui/keymap.test.ts | 6 ++++++ src/ui/keymap.ts | 5 ++++- 5 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 441dd469..c39f1e6c 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -564,7 +564,7 @@ export function App({ setEditingFolder(true); return; } - if (input === "t") { + if (input === "t" && !(region === "content" && section !== "downloads" && section !== "seeding")) { setShowHelp(false); setEditingTrackers(true); return; diff --git a/src/ui/components/Results.test.tsx b/src/ui/components/Results.test.tsx index 7cfa2515..986ed681 100644 --- a/src/ui/components/Results.test.tsx +++ b/src/ui/components/Results.test.tsx @@ -184,4 +184,25 @@ describe("Results filter UI", () => { await vi.waitFor(() => expect(u.frame()).not.toContain("Filter")); expect(u.frame()).toContain("Results (8)"); }); + + it("t exports the selected result without starting a download", async () => { + const fetchAndExportTorrent = vi.fn(); + searchState.current = settled(LIST); + ui = renderUI( + + + , + ); + const u = ui; + await vi.waitFor(() => expect(u.frame()).toContain("Results (8)")); + + u.press("t"); + await vi.waitFor(() => expect(fetchAndExportTorrent).toHaveBeenCalledTimes(1)); + expect(fetchAndExportTorrent).toHaveBeenCalledWith({ + id: "a1", + name: "ubuntu 24.04 desktop amd64 iso", + magnet: "magnet:?xt=urn:btih:a1", + }); + expect(u.frame()).toContain("Results (8)"); + }); }); diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx index c28623e6..bcf827bc 100644 --- a/src/ui/components/Results.tsx +++ b/src/ui/components/Results.tsx @@ -108,6 +108,11 @@ function Detail({ r, width }: { r: TorrentResult; width: number }) { Export {` ${ICON.dot} `} + + t + + .torrent only + {` ${ICON.dot} `} esc back @@ -253,6 +258,9 @@ export function Results() { } else if (input === "y") { const r = results[clamped]; if (r) copyResultMagnet(r); + } else if (input === "t") { + const r = results[clamped]; + if (r) fetchAndExportTorrent({ id: r.infoHash, name: r.name, magnet: r.magnet }); } }, { isActive: focused && mode === "list" }, @@ -268,6 +276,8 @@ export function Results() { else if (input === "y" && detail) copyResultMagnet(detail); else if (input === "e" && detail) fetchAndExportTorrent({ id: detail.infoHash, name: detail.name, magnet: detail.magnet }); + else if (input === "t" && detail) + fetchAndExportTorrent({ id: detail.infoHash, name: detail.name, magnet: detail.magnet }); }, { isActive: focused && mode === "detail" }, ); diff --git a/src/ui/keymap.test.ts b/src/ui/keymap.test.ts index ad6d2c1e..51b4909d 100644 --- a/src/ui/keymap.test.ts +++ b/src/ui/keymap.test.ts @@ -23,6 +23,12 @@ describe("downloads/seeding key vocabulary", () => { expect(seeding.find((h) => h.keys === "c")?.label).toBe("Remove from list"); }); + it("shows the torrent-only action in the results footer", () => { + const row = footerHints("content", "all", null, null); + expect(row.some((h) => h.keys === "t")).toBe(true); + expect(row.find((h) => h.keys === "t")?.label).toBe(".torrent only"); + }); + // The results row carries a known pre-existing overflow (f Filter), so the // budget is pinned only for the rows this vocabulary owns. it("keeps the downloads and seeding footer rows inside the 80-col budget", () => { diff --git a/src/ui/keymap.ts b/src/ui/keymap.ts index 48b56e75..6fa918b8 100644 --- a/src/ui/keymap.ts +++ b/src/ui/keymap.ts @@ -19,7 +19,7 @@ export const HELP_GROUPS: HelpGroup[] = [ { keys: "tab", label: "Switch pane" }, { keys: "esc", label: "Back" }, { keys: "o", label: "Default download folder" }, - { keys: "t", label: "Extra trackers" }, + { keys: "t", label: "Trackers / .torrent only in results" }, { keys: "q", label: "Quit" }, ], }, @@ -73,6 +73,8 @@ const TORRENT: Hint = { keys: "s", label: "Export" }; const EXPORT: Hint = { keys: "e", label: "Export" }; +const TORRENT_ONLY: Hint = { keys: "t", label: ".torrent only" }; + export function footerHints( region: Region, section: Section, @@ -122,6 +124,7 @@ export function footerHints( { keys: "d", label: "Download" }, { keys: "y", label: "Copy" }, resultFocus === "detail" ? EXPORT : { keys: "s", label: "Sort" }, + TORRENT_ONLY, { keys: "/", label: "Search" }, { keys: "f", label: "Filter" }, SWITCH, From 75cc5f8cd0a978ef67dda2839280d034703decfa Mon Sep 17 00:00:00 2001 From: walter Date: Fri, 4 Sep 2026 10:23:56 -0300 Subject: [PATCH 3/3] feat: implement debug logging for torrent export and metadata fetching --- src/download/queue.ts | 19 +++++++++++++++++-- src/ui/App.tsx | 7 +++++++ src/util/crashlog.ts | 11 +++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/download/queue.ts b/src/download/queue.ts index 21c990eb..74adf956 100644 --- a/src/download/queue.ts +++ b/src/download/queue.ts @@ -17,6 +17,7 @@ import { deleteSeedData } from "./delete-data"; import { disarmBootMarker } from "./bootguard"; import type { QueueItem, SeedItem } from "./types"; import type { SourceId } from "../sources/types"; +import { logDebug } from "../util/crashlog"; /** * A real seed never pulls data off the network: verifying on-disk files reads @@ -419,13 +420,19 @@ export class DownloadQueue extends EventEmitter { input: { id: string; name: string; magnet: string }, exportDir: string, ): Promise { + logDebug("torrent-only", `request id=${input.id} name=${JSON.stringify(input.name)} exportDir=${JSON.stringify(exportDir)}`); // Fast path: cached from a previous download. if (torrentMetaExists(input.id)) { - return exportTorrentMeta(input.id, input.name, exportDir); + logDebug("torrent-only", `cached hit id=${input.id}`); + return exportTorrentMeta(input.id, input.name, exportDir).then((file) => { + logDebug("torrent-only", `cached export id=${input.id} file=${JSON.stringify(file)}`); + return file; + }); } // 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)) { + logDebug("torrent-only", `blocked existing live torrent id=${input.id}`); return Promise.resolve(null); } return new Promise((resolve) => { @@ -434,6 +441,7 @@ export class DownloadQueue extends EventEmitter { const timer = setTimeout(() => { if (done) return; done = true; + logDebug("torrent-only", `metadata timeout id=${input.id}`); this.engine.remove(tempKey); resolve(null); }, FETCH_METADATA_TIMEOUT_MS); @@ -442,17 +450,24 @@ export class DownloadQueue extends EventEmitter { if (done) return; done = true; clearTimeout(timer); + logDebug( + "torrent-only", + `metadata arrived id=${input.id} name=${JSON.stringify(meta.name)} total=${meta.total} files=${meta.files} hasTorrentFile=${Boolean(meta.torrentFile)}`, + ); // Tear down synchronously before any file data can be written. this.engine.remove(tempKey); void (async () => { if (meta.torrentFile) await saveTorrentMeta(input.id, meta.torrentFile); - resolve(await exportTorrentMeta(input.id, input.name, exportDir)); + const file = await exportTorrentMeta(input.id, input.name, exportDir); + logDebug("torrent-only", `export result id=${input.id} file=${JSON.stringify(file)}`); + resolve(file); })(); }, onError: () => { if (done) return; done = true; clearTimeout(timer); + logDebug("torrent-only", `metadata error id=${input.id}`); this.engine.remove(tempKey); resolve(null); }, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c39f1e6c..e6d0b7fa 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -20,6 +20,7 @@ import { resolveTorrentPath } from "../sources/torrentPath"; import { readClipboard, writeClipboard } from "../util/clipboard"; import { openFolder } from "../util/openFolder"; import { cleanText, formatBytes, truncate } from "../util/format"; +import { logDebug } from "../util/crashlog"; import { StoreContext, type CaptureMode, @@ -358,11 +359,14 @@ export function App({ (input: { id: string; name: string }) => { if (!queue) return; void (async () => { + logDebug("export", `cached-request id=${input.id} name=${JSON.stringify(input.name)}`); const file = await queue.exportTorrentFile(input.id); if (file) { + logDebug("export", `cached-success id=${input.id} file=${JSON.stringify(file)}`); setNotice(`Exported torrent file: ${truncate(file, 48)}`); return; } + logDebug("export", `cached-miss id=${input.id}`); setNotice(`No torrent file yet for ${truncate(cleanText(input.name), 32)}.`); })(); }, @@ -373,12 +377,15 @@ export function App({ (input: { id: string; name: string; magnet: string }) => { if (!queue || !config) return; setNotice("Fetching torrent metadata…"); + logDebug("torrent-only", `ui-request id=${input.id} name=${JSON.stringify(input.name)} downloadDir=${JSON.stringify(config.downloadDir)}`); void (async () => { const file = await queue.fetchAndExportTorrent(input, config.downloadDir); if (file) { + logDebug("torrent-only", `ui-success id=${input.id} file=${JSON.stringify(file)}`); setNotice(`Exported torrent file: ${truncate(file, 48)}`); return; } + logDebug("torrent-only", `ui-failure id=${input.id}`); setNotice(`Couldn't export torrent file for ${truncate(cleanText(input.name), 32)}.`); })(); }, diff --git a/src/util/crashlog.ts b/src/util/crashlog.ts index 281e2b94..c28a1c4b 100644 --- a/src/util/crashlog.ts +++ b/src/util/crashlog.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { logsDir } from "../config/paths"; export const crashLogFile = path.join(logsDir, "crash.log"); +export const debugLogFile = path.join(logsDir, "debug.log"); // Append one timestamped entry. Returns false when even logging failed: a // crash logger must never become a crash source itself. @@ -17,6 +18,16 @@ export function logCrash(kind: string, err: unknown): boolean { } } +export function logDebug(kind: string, message: string): boolean { + try { + mkdirSync(logsDir, { recursive: true }); + appendFileSync(debugLogFile, `${new Date().toISOString()} [${kind}] ${message}\n`); + return true; + } catch { + return false; + } +} + // Node kills the process on any unhandled promise rejection, and webtorrent // can produce one from inside its own async internals with no error event and // nothing a caller's try/catch can reach (its fire-and-forget _onTorrentId is