Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
49c6b62
fix: restore bin path prefix for npm
pseudoshell Aug 10, 2026
74176b0
1.6.1
pseudoshell Aug 10, 2026
7177725
feat: add Bookmarks feature with sidebar panel, persistence, and b ho…
pseudoshell Aug 10, 2026
3cfa366
1.6.2
pseudoshell Aug 10, 2026
67677a9
fix(update): update branding to torhunt for update command and versio…
pseudoshell Aug 10, 2026
1dbb997
style: remove theme name badge from top-right header bar
pseudoshell Aug 10, 2026
ddd629f
feat(settings): display torhunt package version in settings panel foo…
pseudoshell Aug 10, 2026
750acc6
feat(ui): place torhunt version in bottom-right footer row
pseudoshell Aug 10, 2026
e7430e3
style(settings): pin torhunt version to the bottom-right inside the s…
pseudoshell Aug 10, 2026
4b6135c
1.7.0
pseudoshell Aug 10, 2026
964bb5e
style(settings): format version as vx.x.x
pseudoshell Aug 10, 2026
9d06b50
feat(defaults): set Electric Cyan as default theme and Signal Meter a…
pseudoshell Aug 10, 2026
68d7f1b
1.7.1
pseudoshell Aug 10, 2026
b7ff893
feat(settings): display update notice beside version in settings panel
pseudoshell Aug 10, 2026
1a78488
style(splash): move update banner below search and browse hints
pseudoshell Aug 10, 2026
44455a8
1.7.2
pseudoshell Aug 10, 2026
d15b733
feat(search): add persistent search history with up/down arrow quick …
pseudoshell Aug 10, 2026
7656c80
1.8.0
pseudoshell Aug 10, 2026
5c24cdc
fix(bookmarks): support Enter key to download bookmarked torrent
pseudoshell Aug 10, 2026
d57f547
feat(power): add stay awake and sleep/shutdown on queue finish in set…
pseudoshell Aug 10, 2026
935754c
style(help): redesign HelpOverlay with sharp Panel brackets design
pseudoshell Aug 10, 2026
2cc3019
style(settings): remove gap between items and remove emoticons
pseudoshell Aug 10, 2026
a3a398d
1.9.0
pseudoshell Aug 10, 2026
35644d1
style(settings): format update notice with square brackets
pseudoshell Aug 10, 2026
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "torhunt",
"version": "1.6.0",
"version": "1.9.0",
"description": "A sleek, zero-setup torrent finder and downloader that lives right in your terminal.",
"type": "module",
"bin": {
Expand Down Expand Up @@ -48,11 +48,11 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/baairon/torlink.git"
"url": "git+https://github.com/pseudoshell/torhunt.git"
},
"homepage": "https://torlink.bairon.dev",
"homepage": "https://github.com/pseudoshell/torhunt",
"bugs": {
"url": "https://github.com/baairon/torlink/issues"
"url": "https://github.com/pseudoshell/torhunt/issues"
},
"publishConfig": {
"access": "public"
Expand Down
10 changes: 10 additions & 0 deletions scripts/render-previews-impl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ function makeStore(
theme: "electric-cyan",
spinner: "dots",
trackers: [],
preventSleep: true,
onComplete: "none",
} as Config,
setConfig: noop,
theme: DEFAULT_THEME,
Expand Down Expand Up @@ -124,6 +126,14 @@ function makeStore(
openFolderPicker: noop,
searchModeTrigger: 0,
triggerSearch: noop,
bookmarks: [],
addBookmark: noop,
removeBookmark: noop,
clearBookmarks: noop,
searchHistory: [],
pushSearchHistory: noop,
clearSearchHistory: noop,
updateVersion: null,
quitAll: noop,
listRows: 14,
compact: false,
Expand Down
16 changes: 15 additions & 1 deletion src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ import { promises as fs } from "node:fs";
import { configFile, defaultDownloadDir } from "./paths";
import { serializeWrites, writeJsonAtomic } from "../util/atomic";

export type OnCompleteAction = "none" | "sleep" | "shutdown";

export interface Config {
downloadDir: string;
trackers: string[];
theme: string;
spinner: string;
preventSleep: boolean;
onComplete: OnCompleteAction;
}

export const defaultConfig: Config = {
downloadDir: defaultDownloadDir,
trackers: [],
theme: "electric-cyan",
spinner: "dots",
spinner: "meter",
preventSleep: true,
onComplete: "none",
};

export async function loadConfig(): Promise<Config> {
Expand All @@ -38,6 +44,14 @@ export async function loadConfig(): Promise<Config> {
typeof parsed.spinner === "string" && parsed.spinner
? parsed.spinner
: defaultConfig.spinner,
preventSleep:
typeof parsed.preventSleep === "boolean"
? parsed.preventSleep
: defaultConfig.preventSleep,
onComplete:
parsed.onComplete === "sleep" || parsed.onComplete === "shutdown" || parsed.onComplete === "none"
? parsed.onComplete
: defaultConfig.onComplete,
};
return cfg;
} catch {
Expand Down
4 changes: 4 additions & 0 deletions src/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export const queueFile = path.join(dataDir, "queue.json");

export const historyFile = path.join(dataDir, "history.json");

export const bookmarksFile = path.join(dataDir, "bookmarks.json");

export const searchHistoryFile = path.join(dataDir, "search-history.json");

export const seedsFile = path.join(dataDir, "seeds.json");

// Per-torrent .torrent metadata, captured during download so a re-seed can
Expand Down
38 changes: 38 additions & 0 deletions src/download/bookmarks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { promises as fs } from "node:fs";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadBookmarks, saveBookmarks, type BookmarkItem } from "./bookmarks";
import { bookmarksFile } from "../config/paths";

describe("bookmarks persistence", () => {
beforeEach(async () => {
await fs.rm(bookmarksFile, { force: true }).catch(() => {});
});

afterEach(async () => {
await fs.rm(bookmarksFile, { force: true }).catch(() => {});
});

it("saves and loads bookmark items", async () => {
const items: BookmarkItem[] = [
{
id: "bm-1",
name: "Test Movie 1080p",
magnet: "magnet:?xt=urn:btih:1234567890abcdef",
source: "yts",
sizeBytes: 1500000000,
bookmarkedAt: Date.now(),
},
];

await saveBookmarks(items);
const loaded = await loadBookmarks();
expect(loaded).toHaveLength(1);
expect(loaded[0]?.id).toBe("bm-1");
expect(loaded[0]?.name).toBe("Test Movie 1080p");
});

it("returns empty array when file does not exist", async () => {
const loaded = await loadBookmarks();
expect(loaded).toEqual([]);
});
});
52 changes: 52 additions & 0 deletions src/download/bookmarks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { promises as fs, mkdirSync, writeFileSync, renameSync } from "node:fs";
import path from "node:path";
import { bookmarksFile } from "../config/paths";
import { serializeWrites, writeJsonAtomic } from "../util/atomic";
import type { SourceId } from "../sources/types";

export const BOOKMARKS_CAP = 200;

export interface BookmarkItem {
id: string;
name: string;
source?: SourceId;
magnet: string;
sizeBytes: number;
bookmarkedAt: number;
}

const write = serializeWrites();

export function saveBookmarks(items: BookmarkItem[]): Promise<void> {
return write(() => writeJsonAtomic(bookmarksFile, items.slice(0, BOOKMARKS_CAP)));
}

export function saveBookmarksSync(items: BookmarkItem[]): void {
try {
mkdirSync(path.dirname(bookmarksFile), { recursive: true });
const tmp = `${bookmarksFile}.sync.tmp`;
writeFileSync(tmp, JSON.stringify(items.slice(0, BOOKMARKS_CAP), null, 2), "utf8");
renameSync(tmp, bookmarksFile);
} catch {}
}

function isBookmarkItem(v: unknown): v is BookmarkItem {
if (!v || typeof v !== "object") return false;
const r = v as Record<string, unknown>;
return typeof r.id === "string" && typeof r.name === "string" && typeof r.magnet === "string";
Comment on lines +33 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Validate all required BookmarkItem fields.

loadBookmarks returns values as BookmarkItem, but this guard accepts records without sizeBytes or bookmarkedAt. A malformed persistence file can then place undefined values in consumers that expect numbers.

Proposed fix
-  return typeof r.id === "string" && typeof r.name === "string" && typeof r.magnet === "string";
+  return (
+    typeof r.id === "string" &&
+    typeof r.name === "string" &&
+    typeof r.magnet === "string" &&
+    typeof r.sizeBytes === "number" &&
+    Number.isFinite(r.sizeBytes) &&
+    r.sizeBytes >= 0 &&
+    typeof r.bookmarkedAt === "number" &&
+    Number.isFinite(r.bookmarkedAt)
+  );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function isBookmarkItem(v: unknown): v is BookmarkItem {
if (!v || typeof v !== "object") return false;
const r = v as Record<string, unknown>;
return typeof r.id === "string" && typeof r.name === "string" && typeof r.magnet === "string";
function isBookmarkItem(v: unknown): v is BookmarkItem {
if (!v || typeof v !== "object") return false;
const r = v as Record<string, unknown>;
return (
typeof r.id === "string" &&
typeof r.name === "string" &&
typeof r.magnet === "string" &&
typeof r.sizeBytes === "number" &&
Number.isFinite(r.sizeBytes) &&
r.sizeBytes >= 0 &&
typeof r.bookmarkedAt === "number" &&
Number.isFinite(r.bookmarkedAt)
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/download/bookmarks.ts` around lines 33 - 36, Update isBookmarkItem to
validate every required BookmarkItem field, including sizeBytes and bookmarkedAt
as numbers, before loadBookmarks accepts persisted records. Preserve the
existing string checks for id, name, and magnet.

}

export async function loadBookmarks(): Promise<BookmarkItem[]> {
let raw: string;
try {
raw = await fs.readFile(bookmarksFile, "utf8");
} catch {
return [];
}
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed.filter(isBookmarkItem).slice(0, BOOKMARKS_CAP) : [];
} catch {
return [];
}
}
73 changes: 73 additions & 0 deletions src/sources/searchHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { promises as fs } from "node:fs";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
addSearchQuery,
clearSearchHistory,
loadSearchHistory,
saveSearchHistory,
} from "./searchHistory";
import { searchHistoryFile } from "../config/paths";

describe("searchHistory module", () => {
beforeEach(async () => {
await fs.rm(searchHistoryFile, { force: true }).catch(() => {});
});

afterEach(async () => {
await fs.rm(searchHistoryFile, { force: true }).catch(() => {});
});

describe("addSearchQuery", () => {
it("adds new search query to the front", () => {
const history = ["Inception", "Interstellar"];
const result = addSearchQuery(history, "Oppenheimer");
expect(result).toEqual(["Oppenheimer", "Inception", "Interstellar"]);
});

it("moves existing duplicate to the front and avoids duplicate entries (case-insensitive)", () => {
const history = ["Oppenheimer", "Inception", "Interstellar"];
const result = addSearchQuery(history, "inception");
expect(result).toEqual(["inception", "Oppenheimer", "Interstellar"]);
});

it("ignores empty or whitespace queries", () => {
const history = ["Inception"];
expect(addSearchQuery(history, "")).toEqual(["Inception"]);
expect(addSearchQuery(history, " ")).toEqual(["Inception"]);
});

it("ignores raw magnet links", () => {
const history = ["Inception"];
expect(
addSearchQuery(history, "magnet:?xt=urn:btih:1234567890abcdef"),
).toEqual(["Inception"]);
});

it("enforces maxItems limit", () => {
const history = ["a", "b", "c"];
const result = addSearchQuery(history, "d", 3);
expect(result).toEqual(["d", "a", "b"]);
});
});

describe("persistence", () => {
it("saves and loads search history from disk", async () => {
const items = ["House of the Dragon", "The Batman", "Dune 2"];
await saveSearchHistory(items);
const loaded = await loadSearchHistory();
expect(loaded).toEqual(items);
});

it("returns empty array when file does not exist", async () => {
const loaded = await loadSearchHistory();
expect(loaded).toEqual([]);
});

it("clears search history", async () => {
await saveSearchHistory(["House of the Dragon"]);
await clearSearchHistory();
const loaded = await loadSearchHistory();
expect(loaded).toEqual([]);
});
});
});
50 changes: 50 additions & 0 deletions src/sources/searchHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { promises as fs } from "node:fs";
import { searchHistoryFile } from "../config/paths";
import { serializeWrites, writeJsonAtomic } from "../util/atomic";

export const MAX_SEARCH_HISTORY = 50;

/**
* Pure helper to add a search query into history list:
* - Trims whitespace
* - Drops empty queries or magnet link URLs
* - Removes existing duplicate if present
* - Inserts the new query at the beginning (most recent first)
* - Limits the list to maxItems (default 50)
*/
export function addSearchQuery(
history: string[],
query: string,
maxItems: number = MAX_SEARCH_HISTORY,
): string[] {
const trimmed = query.trim();
if (!trimmed) return history;
// Don't clutter search history with raw magnet URIs
if (/^magnet:\?/i.test(trimmed)) return history;

const withoutDuplicate = history.filter(
(item) => item.toLowerCase() !== trimmed.toLowerCase(),
);
return [trimmed, ...withoutDuplicate].slice(0, maxItems);
}

const write = serializeWrites();

export async function loadSearchHistory(): Promise<string[]> {
try {
const raw = await fs.readFile(searchHistoryFile, "utf8");
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
Comment on lines +33 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Enforce the history cap during load.

loadSearchHistory returns every valid entry from disk. A file with more than 50 entries bypasses MAX_SEARCH_HISTORY until another search occurs. Apply the cap after filtering.

Proposed fix
-    return parsed.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
+    return parsed
+      .filter((item): item is string => typeof item === "string" && item.trim().length > 0)
+      .slice(0, MAX_SEARCH_HISTORY);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function loadSearchHistory(): Promise<string[]> {
try {
const raw = await fs.readFile(searchHistoryFile, "utf8");
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
export async function loadSearchHistory(): Promise<string[]> {
try {
const raw = await fs.readFile(searchHistoryFile, "utf8");
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
.slice(0, MAX_SEARCH_HISTORY);
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 34-34: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use.
Context: fs.readFile(searchHistoryFile, "utf8")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(detect-non-literal-fs-filename-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/sources/searchHistory.ts` around lines 33 - 38, Update loadSearchHistory
to filter valid non-empty strings first, then limit the resulting entries to
MAX_SEARCH_HISTORY before returning them. Preserve the existing empty-array
behavior for invalid or non-array JSON.

} catch {
return [];
}
}

export function saveSearchHistory(history: string[]): Promise<void> {
return write(() => writeJsonAtomic(searchHistoryFile, history));
}

export function clearSearchHistory(): Promise<void> {
return saveSearchHistory([]);
}
Loading
Loading