diff --git a/package-lock.json b/package-lock.json index b95d189..9ecd3c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "torlnk", - "version": "1.6.0", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "torlnk", - "version": "1.6.0", + "version": "1.9.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 53dda28..4c95cf6 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -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" diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx index 7f96ab0..5c16724 100644 --- a/scripts/render-previews-impl.tsx +++ b/scripts/render-previews-impl.tsx @@ -87,6 +87,8 @@ function makeStore( theme: "electric-cyan", spinner: "dots", trackers: [], + preventSleep: true, + onComplete: "none", } as Config, setConfig: noop, theme: DEFAULT_THEME, @@ -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, diff --git a/src/config/config.ts b/src/config/config.ts index f5f1111..6e3ae30 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -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 { @@ -38,6 +44,14 @@ export async function loadConfig(): Promise { 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 { diff --git a/src/config/paths.ts b/src/config/paths.ts index 13c2bc0..410008e 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -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 diff --git a/src/download/bookmarks.test.ts b/src/download/bookmarks.test.ts new file mode 100644 index 0000000..7559781 --- /dev/null +++ b/src/download/bookmarks.test.ts @@ -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([]); + }); +}); diff --git a/src/download/bookmarks.ts b/src/download/bookmarks.ts new file mode 100644 index 0000000..0a83920 --- /dev/null +++ b/src/download/bookmarks.ts @@ -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 { + 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; + return typeof r.id === "string" && typeof r.name === "string" && typeof r.magnet === "string"; +} + +export async function loadBookmarks(): Promise { + 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 []; + } +} diff --git a/src/sources/searchHistory.test.ts b/src/sources/searchHistory.test.ts new file mode 100644 index 0000000..d89fdac --- /dev/null +++ b/src/sources/searchHistory.test.ts @@ -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([]); + }); + }); +}); diff --git a/src/sources/searchHistory.ts b/src/sources/searchHistory.ts new file mode 100644 index 0000000..9ef71bd --- /dev/null +++ b/src/sources/searchHistory.ts @@ -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 { + 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); + } catch { + return []; + } +} + +export function saveSearchHistory(history: string[]): Promise { + return write(() => writeJsonAtomic(searchHistoryFile, history)); +} + +export function clearSearchHistory(): Promise { + return saveSearchHistory([]); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index bc20a7f..fae2f07 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -6,6 +6,13 @@ import { normalizeDownloadDir } from "../config/folder"; import { DownloadQueue } from "../download/queue"; import { loadQueue, loadSeeds } from "../download/persist"; import { loadHistory } from "../download/history"; +import { loadBookmarks, saveBookmarks, type BookmarkItem } from "../download/bookmarks"; +import { + addSearchQuery, + loadSearchHistory, + saveSearchHistory, + clearSearchHistory, +} from "../sources/searchHistory"; import { reconcileQueue } from "../download/reconcile"; import { BOOT_SETTLE_MS, @@ -19,6 +26,12 @@ import { magnetFromTorrentFile } from "../sources/torrentFile"; import { readClipboard, writeClipboard } from "../util/clipboard"; import { openFolder } from "../util/openFolder"; import { cleanText, formatBytes, truncate } from "../util/format"; +import { + acquireKeepAwake, + releaseKeepAwake, + triggerSleep, + triggerShutdown, +} from "../util/power"; import { StoreContext, type CaptureMode, @@ -39,6 +52,7 @@ import { HelpOverlay } from "./components/HelpOverlay"; import { Results } from "./components/Results"; import { Downloads } from "./components/Downloads"; import { Seeding } from "./components/Seeding"; +import { BookmarksView } from "./components/BookmarksView"; import { CompletedView } from "./components/CompletedView"; import { SettingsView } from "./components/SettingsView"; import { Spinner } from "./components/Spinner"; @@ -108,10 +122,18 @@ export function App({ const [editingSpinner, setEditingSpinner] = useState(false); const [previewSpinnerId, setPreviewSpinnerId] = useState(null); const [searchModeTrigger, setSearchModeTrigger] = useState(0); + const [bookmarks, setBookmarks] = useState([]); + const [searchHistory, setSearchHistory] = useState([]); const triggerSearch = useCallback(() => { setShowHelp(false); - if (section === "downloads" || section === "seeding" || section === "completed" || section === "settings") { + if ( + section === "downloads" || + section === "seeding" || + section === "bookmarks" || + section === "completed" || + section === "settings" + ) { setSection("all"); } setRegion("content"); @@ -155,6 +177,12 @@ export function App({ q.restore(reconcileQueue(await loadQueue()), { safe: safeBoot }); q.restoreHistory(await loadHistory()); q.restoreSeeds(await loadSeeds(), { safe: safeBoot }); + const loadedBookmarks = await loadBookmarks(); + const loadedSearchHistory = await loadSearchHistory(); + if (alive) { + setBookmarks(loadedBookmarks); + setSearchHistory(loadedSearchHistory); + } } catch (e) { logCrash("boot-restore", e); } @@ -205,14 +233,39 @@ export function App({ }, []); useEffect(() => { - if (!queue) return; - const onCompleted = (name: string): void => + if (!queue || !config) return; + + const updatePowerState = () => { + const active = queue.activeCount > 0; + if (active && (config.preventSleep ?? true)) { + acquireKeepAwake(); + } else { + releaseKeepAwake(); + } + }; + + updatePowerState(); + queue.on("change", updatePowerState); + + const onCompleted = (name: string): void => { setNotice(`${ICON.done} ${truncate(cleanText(name), 40)}`); + if (queue.activeCount === 0 && queue.getItems().length === 0) { + releaseKeepAwake(); + if (config.onComplete === "sleep") { + triggerSleep(); + } else if (config.onComplete === "shutdown") { + triggerShutdown(); + } + } + }; queue.on("completed", onCompleted); + return () => { + queue.off("change", updatePowerState); queue.off("completed", onCompleted); + releaseKeepAwake(); }; - }, [queue]); + }, [queue, config]); useEffect( () => () => { @@ -442,6 +495,66 @@ export function App({ [queue, config], ); + const addBookmark = useCallback( + (input: { + id: string; + name: string; + magnet: string; + source?: SourceId; + sizeBytes?: number; + }) => { + setBookmarks((prev) => { + if (prev.some((b) => b.id === input.id)) { + setNotice(`Already bookmarked: ${truncate(cleanText(input.name), 40)}`); + return prev; + } + const next: BookmarkItem[] = [ + { + id: input.id, + name: input.name, + magnet: input.magnet, + source: input.source, + sizeBytes: input.sizeBytes ?? 0, + bookmarkedAt: Date.now(), + }, + ...prev, + ]; + void saveBookmarks(next); + setNotice(`★ Bookmarked: ${truncate(cleanText(input.name), 40)}`); + return next; + }); + }, + [], + ); + + const removeBookmark = useCallback((id: string) => { + setBookmarks((prev) => { + const next = prev.filter((b) => b.id !== id); + void saveBookmarks(next); + setNotice("Bookmark removed"); + return next; + }); + }, []); + + const clearBookmarks = useCallback(() => { + setBookmarks([]); + void saveBookmarks([]); + setNotice("All bookmarks cleared"); + }, []); + + const pushSearchHistory = useCallback((q: string) => { + setSearchHistory((prev) => { + const next = addSearchQuery(prev, q); + void saveSearchHistory(next); + return next; + }); + }, []); + + const clearSearchHistoryCallback = useCallback(() => { + setSearchHistory([]); + void clearSearchHistory(); + }, []); + const submitQuery = useCallback( (raw: string) => { const q = raw.trim(); @@ -456,13 +569,14 @@ export function App({ setView("browser"); return; } + pushSearchHistory(q); } setQuery(q); setView("browser"); if (section === "downloads") setSection("all"); setRegion("content"); }, - [section, startDownload], + [section, startDownload, pushSearchHistory], ); const pasteFromClipboard = useCallback(async () => { @@ -548,6 +662,14 @@ export function App({ openFolderPicker: () => setEditingFolder(true), searchModeTrigger, triggerSearch, + bookmarks, + addBookmark, + removeBookmark, + clearBookmarks, + searchHistory, + pushSearchHistory, + clearSearchHistory: clearSearchHistoryCallback, + updateVersion, quitAll, listRows, compact, @@ -565,15 +687,8 @@ export function App({ setSpinnerId, view, query, - submitQuery, section, region, - showHelp, - editingFolder, - editingTrackers, - editingTheme, - editingSpinner, - pendingDownload, captureMode, downloadFocus, seedFocus, @@ -585,13 +700,28 @@ export function App({ exportTorrent, fetchAndExportTorrent, notice, + showHelp, + editingFolder, + editingTrackers, + editingTheme, + editingSpinner, + pendingDownload, + searchModeTrigger, + triggerSearch, + bookmarks, + addBookmark, + removeBookmark, + clearBookmarks, + searchHistory, + pushSearchHistory, + clearSearchHistoryCallback, + updateVersion, + quitAll, listRows, compact, contentWidth, cols, rows, - setConfig, - quitAll, ]); useInput( @@ -800,6 +930,8 @@ export function App({ ) : section === "seeding" ? ( + ) : section === "bookmarks" ? ( + ) : section === "completed" ? ( ) : section === "settings" ? ( @@ -823,7 +955,10 @@ export function App({ : "flex" } > -