Skip to content
Open
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
19 changes: 17 additions & 2 deletions src/download/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -419,13 +420,19 @@ export class DownloadQueue extends EventEmitter {
input: { id: string; name: string; magnet: string },
exportDir: string,
): Promise<string | null> {
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<string | null>((resolve) => {
Expand All @@ -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);
Expand All @@ -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);
},
Expand Down
9 changes: 8 additions & 1 deletion src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)}.`);
})();
},
Expand All @@ -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)}.`);
})();
},
Expand Down Expand Up @@ -564,7 +571,7 @@ export function App({
setEditingFolder(true);
return;
}
if (input === "t") {
if (input === "t" && !(region === "content" && section !== "downloads" && section !== "seeding")) {
setShowHelp(false);
setEditingTrackers(true);
return;
Expand Down
21 changes: 21 additions & 0 deletions src/ui/components/Results.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<StoreContext.Provider value={makeTestStore({ query: "linux iso", fetchAndExportTorrent })}>
<Results />
</StoreContext.Provider>,
);
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)");
});
});
10 changes: 10 additions & 0 deletions src/ui/components/Results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ function Detail({ r, width }: { r: TorrentResult; width: number }) {
</Text>
<Text color={COLOR.text}> Export</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.accent} bold>
t
</Text>
<Text color={COLOR.text}> .torrent only</Text>
<Text dimColor>{` ${ICON.dot} `}</Text>
<Text color={COLOR.alt}>esc</Text>
<Text dimColor> back</Text>
</Box>
Expand Down Expand Up @@ -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" },
Expand All @@ -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" },
);
Expand Down
6 changes: 6 additions & 0 deletions src/ui/keymap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
5 changes: 4 additions & 1 deletion src/ui/keymap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/ui/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/util/crashlog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down