diff --git a/README.md b/README.md index a6af715e..1ce2fc30 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ Klink expands on upstream torlink with key power-user features: - 🔍 **Peer Inspector Pane**: Press `b` on an active download to view live swarm peer connections, IP addresses, client software strings, and individual peer transfer rates. - đŸĸ **Turtle Mode (Bandwidth Throttling)**: Toggle global download and upload speed limits on the fly with a single keypress (`b`) for low-bandwidth or shared network environments. - 📁 **File Inspection & Selective Download**: Inspect multi-file torrent contents before or during download, view nested file trees, and toggle individual files on or off. +- â„šī¸ **Metadata Inspector**: View detailed metadata (like plot summaries, tracklists, or release notes) for a torrent before downloading. +- đŸ“ģ **RSS Auto-Downloader**: A background daemon for subscribing to RSS feeds and automatically downloading new items as they are released. +- âš™ī¸ **Advanced Search Filtering**: Filter and refine search results by properties like seeders, size, and category. +- â¯ī¸ **Sequential Downloading**: Toggle sequential block downloading to prioritise early pieces, enabling immediate playback of media. +- 🌐 **Network Interface Binding**: Force all torrent traffic through a specific network interface (like a VPN tunnel) to prevent IP leaks. - đŸŽŦ **Direct Media Streaming & Themed Web UI**: Stream video files directly over HTTP while downloading (`klink serve` / `klink files`), with an integrated web interface featuring a dark/light theme switcher. - ✅ **Completed Tab & Smart File Organisation**: Cleanly separate active downloads, seeding items, and finished downloads with directory routing. - âš ī¸ **Action Confirmation Dialogs**: Safety confirmation prompts before destructive actions like cancelling downloads or clearing history to prevent accidental data loss. @@ -65,6 +70,8 @@ A short, hand-picked list of trusted sources: | Movies | YTS, The Pirate Bay, 1337x, BitTorrented | | TV | EZTV, The Pirate Bay, 1337x, BitTorrented | | Anime | Nyaa, SubsPlease | +| E-Books | The Pirate Bay, 1337x, BitTorrented | +| Audiobooks | The Pirate Bay, 1337x, BitTorrented | Games are the only category that can run code, so they come from FitGirl alone, a repacker with a long, trusted track record; everything else is plain video and subtitles. If a source is down, the search carries on without it, and Klink tells you which one is offline. @@ -114,6 +121,11 @@ Before opening a PR, skim [CONTRIBUTING.md](CONTRIBUTING.md); it lays out the ba | Peer Inspector | ❌ | ✅ | | Turtle Mode | ❌ | ✅ | | File Inspection | ❌ | ✅ | +| Metadata Inspector | ❌ | ✅ | +| RSS Auto-Downloader | ❌ | ✅ | +| Advanced Search Filtering | ❌ | ✅ | +| Sequential Downloading | ❌ | ✅ | +| Network Interface Binding | ❌ | ✅ | | Media Streaming | ❌ | ✅ | | Completion Tab | ❌ | ✅ | | Confirmation Dialogs | ❌ | ✅ | diff --git a/src/config/config.ts b/src/config/config.ts index 888a3135..ef5a5a26 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -18,6 +18,7 @@ export interface Config { throttleUploadLimit: number; webServerEnabled: boolean; webServerPort: number; + bindAddress: string | null; autoDownloads: AutoDownloadRule[]; autoDownloadIntervalMs: number; } @@ -30,6 +31,7 @@ export const defaultConfig: Config = { throttleUploadLimit: 500000, webServerEnabled: false, webServerPort: 8080, + bindAddress: null, autoDownloads: [], autoDownloadIntervalMs: 3600000, // 1 hour default }; @@ -56,6 +58,7 @@ export async function loadConfig(): Promise { throttleUploadLimit: typeof parsed.throttleUploadLimit === "number" ? parsed.throttleUploadLimit : 500000, webServerEnabled: typeof parsed.webServerEnabled === "boolean" ? parsed.webServerEnabled : false, webServerPort: typeof parsed.webServerPort === "number" ? parsed.webServerPort : 8080, + bindAddress: typeof parsed.bindAddress === "string" ? parsed.bindAddress : null, autoDownloads: Array.isArray(parsed.autoDownloads) ? (parsed.autoDownloads.filter((a: any) => typeof a?.source === "string" && typeof a?.query === "string" && typeof a?.match === "string") as AutoDownloadRule[]) : [], diff --git a/src/daemon/autodownload.test.ts b/src/daemon/autodownload.test.ts index d9142822..2597168f 100644 --- a/src/daemon/autodownload.test.ts +++ b/src/daemon/autodownload.test.ts @@ -24,6 +24,7 @@ describe("AutoDownloader", () => { throttleUploadLimit: 0, webServerEnabled: false, webServerPort: 8080, + bindAddress: null, autoDownloads: [], autoDownloadIntervalMs: 1000, }; diff --git a/src/daemon/runtime.ts b/src/daemon/runtime.ts index 7defc470..747680c3 100644 --- a/src/daemon/runtime.ts +++ b/src/daemon/runtime.ts @@ -34,6 +34,9 @@ export interface Runtime { // back to the saved config's dir when the caller doesn't override it. export async function startRuntime(overrideDir?: string): Promise { const cfg = await loadConfig(); + const { applyNetworkBinding } = await import("../download/network"); + applyNetworkBinding(cfg); + const queue = new DownloadQueue(); queue.setTrackers(cfg.trackers); // Crash-boot breaker, mirroring the TUI: a marker left by the previous run diff --git a/src/download/network.test.ts b/src/download/network.test.ts new file mode 100644 index 00000000..ea246fcb --- /dev/null +++ b/src/download/network.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockNetConnect, mockDgramBind, mockDgramCreateSocket } = vi.hoisted(() => { + const mockDgramBind = vi.fn(); + return { + mockNetConnect: vi.fn(), + mockDgramBind, + mockDgramCreateSocket: vi.fn(() => ({ bind: mockDgramBind })), + }; +}); + +vi.mock("node:net", () => ({ + default: { connect: mockNetConnect }, + connect: mockNetConnect, +})); + +vi.mock("node:dgram", () => ({ + default: { createSocket: mockDgramCreateSocket }, + createSocket: mockDgramCreateSocket, +})); + +import net from "node:net"; +import dgram from "node:dgram"; +import { applyNetworkBinding } from "./network"; +import type { Config } from "../config/config"; +import { defaultConfig } from "../config/config"; + +describe("Network Binding", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("injects localAddress into net.connect when configured", () => { + const cfg: Config = { ...defaultConfig, bindAddress: "10.8.0.2" }; + applyNetworkBinding(cfg); + + net.connect(80, "example.com"); + + expect(mockNetConnect).toHaveBeenCalledWith( + expect.objectContaining({ port: 80, host: "example.com", localAddress: "10.8.0.2" }) + ); + }); + + it("injects address into dgram socket bind when configured", () => { + const cfg: Config = { ...defaultConfig, bindAddress: "10.8.0.2" }; + applyNetworkBinding(cfg); + + const socket = dgram.createSocket("udp4"); + expect(mockDgramCreateSocket).toHaveBeenCalled(); + + socket.bind(1234); + expect(mockDgramBind).toHaveBeenCalledWith(1234, "10.8.0.2"); + }); +}); diff --git a/src/download/network.ts b/src/download/network.ts new file mode 100644 index 00000000..4aa833c9 --- /dev/null +++ b/src/download/network.ts @@ -0,0 +1,82 @@ +import net from "node:net"; +import dgram from "node:dgram"; +import type { Config } from "../config/config"; + +const originalNetConnect = net.connect; +const originalNetCreateConnection = net.createConnection; +const originalDgramCreateSocket = dgram.createSocket; + +let activeBindAddress: string | null = null; +let patched = false; + +export function applyNetworkBinding(config: Config) { + activeBindAddress = config.bindAddress; + + if (patched) return; + patched = true; + + // Patch net.connect and net.createConnection + const patchNet = (original: any) => { + return function (this: any, ...args: any[]) { + if (activeBindAddress) { + if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) { + args[0].localAddress = activeBindAddress; + } else if (typeof args[0] === "number" || typeof args[0] === "string") { + // It's a port or path, we need to inject an options object + const options = { + [typeof args[0] === "number" ? "port" : "path"]: args[0], + host: typeof args[1] === "string" ? args[1] : undefined, + localAddress: activeBindAddress, + }; + // The callback might be arg 1 or arg 2 + const cb = typeof args[args.length - 1] === "function" ? args[args.length - 1] : undefined; + args = cb ? [options, cb] : [options]; + } + } + return original.apply(this, args); + }; + }; + + (net as any).connect = patchNet(originalNetConnect); + (net as any).createConnection = patchNet(originalNetCreateConnection); + + // Patch dgram.createSocket + (dgram as any).createSocket = function (this: any, ...args: any[]) { + const socket = originalDgramCreateSocket.apply(this, args as any); + if (activeBindAddress) { + const originalBind = socket.bind; + socket.bind = function (this: any, ...bindArgs: any[]) { + if (bindArgs.length > 0 && typeof bindArgs[0] === "object" && bindArgs[0] !== null) { + if (!bindArgs[0].address) bindArgs[0].address = activeBindAddress; + } else if (typeof bindArgs[0] === "number") { + const port = bindArgs[0]; + let address = activeBindAddress; + let cb = undefined; + + if (typeof bindArgs[1] === "string") { + // Some modules might bind to localhost explicitly, but we want to force our bind. + // Wait, forcing localhost to VPN interface breaks local IPC! + // We should only force bind if it's binding to 0.0.0.0 or if it's leaving the host. + // But usually BitTorrent sockets just bind to port 0. + if (bindArgs[1] === "0.0.0.0" || bindArgs[1] === "::") { + address = activeBindAddress; + } else { + address = bindArgs[1]; + } + cb = bindArgs[2]; + } else if (typeof bindArgs[1] === "function") { + cb = bindArgs[1]; + } + + bindArgs = cb ? [port, address, cb] : [port, address]; + } else if (bindArgs.length === 0 || (bindArgs.length === 1 && typeof bindArgs[0] === "function")) { + // Binding to random port + const cb = bindArgs[0]; + bindArgs = cb ? [0, activeBindAddress, cb] : [0, activeBindAddress]; + } + return (originalBind as any).apply(this, bindArgs); + }; + } + return socket; + }; +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 569e0649..5cc110f0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -13,6 +13,7 @@ import { disarmBootMarker, wasBootInterrupted, } from "../download/bootguard"; +import { applyNetworkBinding } from "../download/network"; import { logCrash } from "../util/crashlog"; import { parseInput } from "../sources/magnet"; import { magnetFromTorrentFile } from "../sources/torrentFile"; @@ -50,6 +51,7 @@ import { Files } from "./components/Files"; import { Splash } from "./views/Splash"; import { FolderPrompt } from "./components/FolderPrompt"; import { TrackersPrompt } from "./components/TrackersPrompt"; +import { NetworkPrompt } from "./components/NetworkPrompt"; import { ConfirmPrompt } from "./components/ConfirmPrompt"; import { footerHints } from "./keymap"; import { COLOR, ICON } from "./theme"; @@ -106,7 +108,7 @@ export function App({ const [showHelp, setShowHelp] = useState(false); const [editingFolder, setEditingFolder] = useState(false); const [editingTrackers, setEditingTrackers] = useState(false); - // A result waiting on the "download to" prompt (D); null when the prompt is + const [editingNetwork, setEditingNetwork] = useState(false); // closed. lastDownloadToDir pre-fills the next prompt so queueing a batch // into the same alternate folder only costs one typed path per session. const [pendingDownload, setPendingDownload] = useState<{ @@ -144,6 +146,7 @@ export function App({ let alive = true; void (async () => { const cfg = await loadConfig(); + applyNetworkBinding(cfg); const q = new DownloadQueue(); q.setTrackers(cfg.trackers); // Crash-boot breaker: a marker left behind by the previous boot means it @@ -255,6 +258,7 @@ export function App({ const setConfig = useCallback( (c: Config) => { setConfigState(c); + applyNetworkBinding(c); queue?.setTrackers(c.trackers); if (autoDownloader) { autoDownloader.setConfig(c); @@ -293,6 +297,24 @@ export function App({ setEditingTrackers(false); }, []); + const closeNetworkPrompt = useCallback(() => { + setEditingNetwork(false); + }, []); + + const setBindAddress = useCallback( + (address: string | null) => { + closeNetworkPrompt(); + if (!config) return; + if (address === config.bindAddress) { + setNotice("Network binding unchanged."); + return; + } + setConfig({ ...config, bindAddress: address }); + setNotice(address ? `Network bound to: ${address}` : "Network binding cleared."); + }, + [config, setConfig, closeNetworkPrompt] + ); + const setTrackers = useCallback( (list: string[]) => { closeTrackersPrompt(); @@ -537,7 +559,7 @@ export function App({ submitQuery, section, setSection, - region: showHelp || editingFolder || editingTrackers || pendingDownload || pendingConfirm ? "help" : region, + region: showHelp || editingFolder || editingTrackers || editingNetwork || pendingDownload || pendingConfirm ? "help" : region, setRegion, captureMode, setCaptureMode, @@ -586,6 +608,7 @@ export function App({ showHelp, editingFolder, editingTrackers, + editingNetwork, pendingDownload, captureMode, downloadFocus, @@ -620,7 +643,7 @@ export function App({ quitAll(); return; } - if (editingFolder || editingTrackers || pendingDownload || pendingConfirm) return; // the prompt owns input (its own esc + enter) + if (editingFolder || editingTrackers || editingNetwork || pendingDownload || pendingConfirm) return; // the prompt owns input (its own esc + enter) if (captureMode === "text") return; if (showHelp) { setShowHelp(false); @@ -648,22 +671,19 @@ export function App({ setEditingTrackers(true); return; } - if (input === "m") { - void pasteFromClipboard(); + if (input === "n") { + setShowHelp(false); + setEditingNetwork(true); return; } - if (input === "w") { - if (inspectingPeersId) setInspectingPeersId(null); + if (input === "m") { + void pasteFromClipboard(); return; } if (input === "w") { if (inspectingPeersId) setInspectingPeersId(null); return; } - if (input === "w") { - if (inspectingId) setInspectingId(null); - return; - } if (key.tab) { if (inspectingId) setInspectingId(null); if (inspectingPeersId) setInspectingPeersId(null); @@ -796,6 +816,17 @@ export function App({ ) : null} + {editingNetwork ? ( + + + + ) : null} + {pendingDownload ? ( void; + onCancel: () => void; +} + +export function NetworkPrompt({ + width, + value, + onSubmit, + onCancel, +}: NetworkPromptProps) { + useInput((_input, key) => { + if (key.escape) onCancel(); + }); + + return ( + + + + + Bind traffic to an IP (e.g. 10.8.0.2). Leave empty for default. + + + + {`${ICON.pointer} `} + + { + const trimmed = val.trim(); + onSubmit(trimmed ? trimmed : null); + }} + /> + + + + + + + + ); +} diff --git a/src/ui/helpLayout.test.ts b/src/ui/helpLayout.test.ts index 05bdb115..05055e40 100644 --- a/src/ui/helpLayout.test.ts +++ b/src/ui/helpLayout.test.ts @@ -3,19 +3,19 @@ import { MEASURED, pickLayout } from "./helpLayout"; describe("help layout measurement", () => { it("derives packing widths and grid heights from HELP_GROUPS", () => { - expect(MEASURED.map((m) => m.width)).toEqual([140, 114, 83, 44]); - expect(MEASURED.map((m) => m.gridH)).toEqual([11, 17, 22, 38]); + expect(MEASURED.map((m) => m.width)).toEqual([141, 115, 84, 44]); + expect(MEASURED.map((m) => m.gridH)).toEqual([11, 17, 23, 39]); }); it("picks the widest packing that fits inside cols - 2", () => { expect(pickLayout(160).layout).toHaveLength(4); - expect(pickLayout(142).layout).toHaveLength(4); - expect(pickLayout(141).layout).toHaveLength(3); - expect(pickLayout(116).layout).toHaveLength(3); - expect(pickLayout(115).layout).toHaveLength(2); - expect(pickLayout(85).layout).toHaveLength(2); + expect(pickLayout(143).layout).toHaveLength(4); + expect(pickLayout(142).layout).toHaveLength(3); + expect(pickLayout(117).layout).toHaveLength(3); + expect(pickLayout(116).layout).toHaveLength(2); + expect(pickLayout(86).layout).toHaveLength(2); + expect(pickLayout(85).layout).toHaveLength(1); expect(pickLayout(84).layout).toHaveLength(1); - expect(pickLayout(83).layout).toHaveLength(1); expect(pickLayout(46).layout).toHaveLength(1); }); }); diff --git a/src/ui/keymap.ts b/src/ui/keymap.ts index 681f1e85..5ef0f945 100644 --- a/src/ui/keymap.ts +++ b/src/ui/keymap.ts @@ -21,6 +21,7 @@ export const HELP_GROUPS: HelpGroup[] = [ { keys: "esc", label: "Back" }, { keys: "o", label: "Default download folder" }, { keys: "t", label: "Extra trackers" }, + { keys: "n", label: "Network interface binding" }, { keys: "b", label: "Turtle Mode (Throttle)" }, { keys: "W", label: "Toggle Web Server" }, { keys: "q", label: "Quit" },