mirror of
https://github.com/baairon/torlink.git
synced 2026-07-08 18:28:22 +02:00
feat: polish prompt ux and fix z hiding sources without seeder data
- new PromptHints action row shared by the folder and trackers prompts - download-to prompt shows the torrent name and size, enter reads "download" - export torrent rebound from T to s and centralized in the store - footer trimmed to essentials, d/D and z detail live in the ? sheet - z keeps FitGirl and SubsPlease rows: their feeds carry no seeder data, a new per-source reportsHealth flag marks seeders: 0 as unknown, not dead - version 1.3.0
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "torlnk",
|
||||
"version": "1.1.1",
|
||||
"version": "1.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "torlnk",
|
||||
"version": "1.1.1",
|
||||
"version": "1.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"env-paths": "^4.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "torlnk",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"description": "A sleek, zero-setup torrent finder and downloader that lives right in your terminal.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -100,6 +100,7 @@ function makeStore(
|
||||
requestDownloadTo: noop,
|
||||
copyMagnet: noop,
|
||||
openDownloadFolder: noop,
|
||||
exportTorrent: noop,
|
||||
notice: null,
|
||||
setNotice: noop,
|
||||
quitAll: noop,
|
||||
|
||||
@@ -54,5 +54,6 @@ export const eztv: Source = {
|
||||
label: "EZTV",
|
||||
group: "TV",
|
||||
homepage: "https://eztvx.to",
|
||||
reportsHealth: true,
|
||||
search,
|
||||
};
|
||||
|
||||
@@ -8,5 +8,7 @@ export const fitgirl: Source = {
|
||||
label: "FitGirl",
|
||||
group: "Games",
|
||||
homepage: HOME,
|
||||
// WordPress RSS carries no swarm data; every result reports seeders: 0.
|
||||
reportsHealth: false,
|
||||
search: (query, opts) => fetchWordpressRss(HOME, "fitgirl", query, opts),
|
||||
};
|
||||
|
||||
@@ -46,5 +46,6 @@ export const nyaa: Source = {
|
||||
label: "Nyaa",
|
||||
group: "Anime",
|
||||
homepage: "https://nyaa.si",
|
||||
reportsHealth: true,
|
||||
search,
|
||||
};
|
||||
|
||||
@@ -79,6 +79,7 @@ export const tpbMovies: Source = {
|
||||
label: "TPB",
|
||||
group: "Movies",
|
||||
homepage: "https://thepiratebay.org",
|
||||
reportsHealth: true,
|
||||
search: (query, opts = {}) => search(query, MOVIE_CATS, TOP_MOVIES, "tpb-movies", opts),
|
||||
};
|
||||
|
||||
@@ -87,5 +88,6 @@ export const tpbTv: Source = {
|
||||
label: "TPB",
|
||||
group: "TV",
|
||||
homepage: "https://thepiratebay.org",
|
||||
reportsHealth: true,
|
||||
search: (query, opts = {}) => search(query, TV_CATS, TOP_TV, "tpb-tv", opts),
|
||||
};
|
||||
|
||||
@@ -71,5 +71,7 @@ export const subsplease: Source = {
|
||||
label: "SubsPlease",
|
||||
group: "Anime",
|
||||
homepage: "https://subsplease.org",
|
||||
// The SubsPlease API has no swarm data; every result reports seeders: 0.
|
||||
reportsHealth: false,
|
||||
search,
|
||||
};
|
||||
|
||||
@@ -32,5 +32,9 @@ export interface Source {
|
||||
label: string;
|
||||
group: SourceGroup;
|
||||
homepage: string;
|
||||
// True when the source returns real swarm counts. False when its feed has
|
||||
// none, so seeders: 0 means unknown, not dead (the alive-only filter must
|
||||
// never drop those rows).
|
||||
reportsHealth: boolean;
|
||||
search(query: string, opts?: SearchOptions): Promise<TorrentResult[]>;
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ export const x1337Movies: Source = {
|
||||
label: "1337x",
|
||||
group: "Movies",
|
||||
homepage: "https://1337x.to",
|
||||
reportsHealth: true,
|
||||
search: (query, opts = {}) => search(query, "Movies", "x1337-movies", opts),
|
||||
};
|
||||
|
||||
@@ -150,5 +151,6 @@ export const x1337Tv: Source = {
|
||||
label: "1337x",
|
||||
group: "TV",
|
||||
homepage: "https://1337x.to",
|
||||
reportsHealth: true,
|
||||
search: (query, opts = {}) => search(query, "TV", "x1337-tv", opts),
|
||||
};
|
||||
|
||||
@@ -76,5 +76,6 @@ export const yts: Source = {
|
||||
label: "YTS",
|
||||
group: "Movies",
|
||||
homepage: "https://yts.mx",
|
||||
reportsHealth: true,
|
||||
search,
|
||||
};
|
||||
|
||||
+24
-1
@@ -11,7 +11,7 @@ import { parseInput } from "../sources/magnet";
|
||||
import { magnetFromTorrentFile } from "../sources/torrentFile";
|
||||
import { readClipboard, writeClipboard } from "../util/clipboard";
|
||||
import { openFolder } from "../util/openFolder";
|
||||
import { cleanText, truncate } from "../util/format";
|
||||
import { cleanText, formatBytes, truncate } from "../util/format";
|
||||
import {
|
||||
StoreContext,
|
||||
type CaptureMode,
|
||||
@@ -307,6 +307,21 @@ export function App({
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const exportTorrent = useCallback(
|
||||
(input: { id: string; name: string }) => {
|
||||
if (!queue) return;
|
||||
void (async () => {
|
||||
const file = await queue.exportTorrentFile(input.id);
|
||||
if (file) {
|
||||
setNotice(`Exported torrent file: ${truncate(file, 48)}`);
|
||||
return;
|
||||
}
|
||||
setNotice(`No torrent file yet for ${truncate(cleanText(input.name), 32)}.`);
|
||||
})();
|
||||
},
|
||||
[queue],
|
||||
);
|
||||
|
||||
const submitQuery = useCallback(
|
||||
(raw: string) => {
|
||||
const q = raw.trim();
|
||||
@@ -389,6 +404,7 @@ export function App({
|
||||
requestDownloadTo,
|
||||
copyMagnet,
|
||||
openDownloadFolder,
|
||||
exportTorrent,
|
||||
notice,
|
||||
setNotice,
|
||||
quitAll,
|
||||
@@ -417,6 +433,7 @@ export function App({
|
||||
requestDownloadTo,
|
||||
copyMagnet,
|
||||
openDownloadFolder,
|
||||
exportTorrent,
|
||||
notice,
|
||||
listRows,
|
||||
compact,
|
||||
@@ -546,6 +563,12 @@ export function App({
|
||||
<FolderPrompt
|
||||
title="download to"
|
||||
width={Math.max(24, Math.min(cols - 4, 62))}
|
||||
subject={
|
||||
pendingDownload.sizeBytes
|
||||
? `${cleanText(pendingDownload.name)} ${ICON.dot} ${formatBytes(pendingDownload.sizeBytes)}`
|
||||
: cleanText(pendingDownload.name)
|
||||
}
|
||||
submitLabel="download"
|
||||
value={lastDownloadToDir ?? store.config.downloadDir}
|
||||
onSubmit={startDownloadTo}
|
||||
onCancel={closeDownloadToPrompt}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function Downloads() {
|
||||
startDownload,
|
||||
openDownloadFolder,
|
||||
setDownloadFocus,
|
||||
setNotice,
|
||||
exportTorrent,
|
||||
} = useStore();
|
||||
const active = useQueueItems(queue);
|
||||
const recent = useQueueHistory(queue);
|
||||
@@ -73,14 +73,9 @@ export function Downloads() {
|
||||
else if (input === "e") {
|
||||
const dir = inActive ? active[clamped]?.dir : recent[recentCursor]?.dir;
|
||||
if (dir) openDownloadFolder(dir);
|
||||
} else if (input === "T") {
|
||||
} else if (input === "s") {
|
||||
const item = inActive ? active[clamped] : recent[recentCursor];
|
||||
if (!item) return;
|
||||
void (async () => {
|
||||
const file = await queue.exportTorrentFile(item.id);
|
||||
if (file) setNotice(`Exported .torrent: ${truncate(file, 48)}`);
|
||||
else setNotice(`No .torrent metadata yet for ${truncate(cleanText(item.name), 32)}.`);
|
||||
})();
|
||||
if (item) exportTorrent({ id: item.id, name: item.name });
|
||||
} else if (inActive) {
|
||||
const it = active[clamped];
|
||||
if (!it) return;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { TextField } from "./TextField";
|
||||
import { Panel } from "./Panel";
|
||||
import { PromptHints } from "./PromptHints";
|
||||
import { COLOR, ICON } from "../theme";
|
||||
|
||||
interface FolderPromptProps {
|
||||
width: number;
|
||||
value: string;
|
||||
title?: string;
|
||||
// One dim context line above the input, e.g. the torrent this download is
|
||||
// for. Without it the prompt is a bare path box with no hint of what the
|
||||
// user is placing.
|
||||
subject?: string;
|
||||
// Verb next to ↵; the default fits the settings prompt, the per-download
|
||||
// prompt passes "download" so enter reads as the action it performs.
|
||||
submitLabel?: string;
|
||||
onSubmit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -14,7 +22,9 @@ interface FolderPromptProps {
|
||||
export function FolderPrompt({
|
||||
width,
|
||||
value,
|
||||
title = "download folder",
|
||||
title = "default download folder",
|
||||
subject,
|
||||
submitLabel = "save",
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: FolderPromptProps) {
|
||||
@@ -24,7 +34,14 @@ export function FolderPrompt({
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Panel title={title} width={width} focused height={2}>
|
||||
<Panel title={title} width={width} focused height={subject ? 3 : 2}>
|
||||
{subject ? (
|
||||
<Box>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
{subject}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box>
|
||||
<Text color={COLOR.accent}>{`${ICON.pointer} `}</Text>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
@@ -37,11 +54,7 @@ export function FolderPrompt({
|
||||
</Box>
|
||||
</Panel>
|
||||
<Box marginTop={1}>
|
||||
<Text color={COLOR.alt}>↵</Text>
|
||||
<Text dimColor> save</Text>
|
||||
<Text dimColor>{` ${ICON.dot} `}</Text>
|
||||
<Text color={COLOR.alt}>esc</Text>
|
||||
<Text dimColor> cancel</Text>
|
||||
<PromptHints submitLabel={submitLabel} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { COLOR, ICON } from "../theme";
|
||||
|
||||
// The one-line action row under a modal prompt: the submit verb carries the
|
||||
// visual weight, esc stays quiet.
|
||||
export function PromptHints({ submitLabel }: { submitLabel: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={COLOR.accent} bold>
|
||||
↵
|
||||
</Text>
|
||||
<Text color={COLOR.text}>{` ${submitLabel}`}</Text>
|
||||
<Text dimColor>{` ${ICON.dot} `}</Text>
|
||||
<Text color={COLOR.alt}>esc</Text>
|
||||
<Text dimColor> cancel</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -93,15 +93,15 @@ function Detail({ r, width }: { r: TorrentResult; width: number }) {
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={COLOR.accent} bold>
|
||||
d/D
|
||||
d
|
||||
</Text>
|
||||
<Text color={COLOR.text}> Download</Text>
|
||||
<Text dimColor>{` ${ICON.dot} `}</Text>
|
||||
<Text dimColor>{` ${ICON.dot} `}</Text>
|
||||
<Text color={COLOR.accent} bold>
|
||||
y
|
||||
</Text>
|
||||
<Text color={COLOR.text}> Copy magnet</Text>
|
||||
<Text dimColor>{` ${ICON.dot} `}</Text>
|
||||
<Text color={COLOR.text}> Copy</Text>
|
||||
<Text dimColor>{` ${ICON.dot} `}</Text>
|
||||
<Text color={COLOR.alt}>esc</Text>
|
||||
<Text dimColor> back</Text>
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { TextField } from "./TextField";
|
||||
import { Panel } from "./Panel";
|
||||
import { PromptHints } from "./PromptHints";
|
||||
import { formatTrackers, parseTrackers } from "../../config/trackers";
|
||||
import { COLOR, ICON } from "../theme";
|
||||
|
||||
@@ -18,7 +19,12 @@ export function TrackersPrompt({ width, value, onSubmit, onCancel }: TrackersPro
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Panel title="extra trackers" width={width} focused height={2}>
|
||||
<Panel title="extra trackers" width={width} focused height={3}>
|
||||
<Box>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
Comma or space separated. Empty clears. Applies to new adds.
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={COLOR.accent}>{`${ICON.pointer} `}</Text>
|
||||
<Box flexGrow={1} minWidth={0}>
|
||||
@@ -30,17 +36,8 @@ export function TrackersPrompt({ width, value, onSubmit, onCancel }: TrackersPro
|
||||
</Box>
|
||||
</Box>
|
||||
</Panel>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box>
|
||||
<Text color={COLOR.alt}>↵</Text>
|
||||
<Text dimColor> save</Text>
|
||||
<Text dimColor>{` ${ICON.dot} `}</Text>
|
||||
<Text color={COLOR.alt}>esc</Text>
|
||||
<Text dimColor> cancel</Text>
|
||||
</Box>
|
||||
<Text dimColor>
|
||||
Separate with commas or spaces. Empty saves an empty list. Applies to new adds.
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<PromptHints submitLabel="save" />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,16 @@ describe("filterResults", () => {
|
||||
expect(filterResults(list, true).map((x) => x.infoHash)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("keeps zero-seeder rows from sources that report no health data", () => {
|
||||
const list = [
|
||||
r({ infoHash: "a", seeders: 0, source: "fitgirl" }),
|
||||
r({ infoHash: "b", seeders: 0 }),
|
||||
r({ infoHash: "c", seeders: 0, source: "subsplease" }),
|
||||
r({ infoHash: "d", seeders: 3 }),
|
||||
];
|
||||
expect(filterResults(list, true).map((x) => x.infoHash)).toEqual(["a", "c", "d"]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const list = [r({ infoHash: "a", seeders: 0 }), r({ infoHash: "b", seeders: 2 })];
|
||||
const before = list.map((x) => x.infoHash);
|
||||
|
||||
+4
-1
@@ -1,3 +1,4 @@
|
||||
import { getSource } from "../sources/registry";
|
||||
import type { TorrentResult } from "../sources/types";
|
||||
|
||||
export function filterResults(
|
||||
@@ -5,5 +6,7 @@ export function filterResults(
|
||||
hideDead: boolean,
|
||||
): TorrentResult[] {
|
||||
if (!hideDead) return list;
|
||||
return list.filter((r) => r.seeders > 0);
|
||||
// Sources without swarm data report seeders: 0 for everything (unknown, not
|
||||
// dead), so the filter only judges rows whose source actually reports health.
|
||||
return list.filter((r) => r.seeders > 0 || !getSource(r.source).reportsHealth);
|
||||
}
|
||||
|
||||
+12
-36
@@ -18,7 +18,7 @@ export const HELP_GROUPS: HelpGroup[] = [
|
||||
{ keys: "↵", label: "Open" },
|
||||
{ keys: "tab", label: "Switch pane" },
|
||||
{ keys: "esc", label: "Back" },
|
||||
{ keys: "o", label: "Download folder" },
|
||||
{ keys: "o", label: "Default download folder" },
|
||||
{ keys: "t", label: "Extra trackers" },
|
||||
{ keys: "q", label: "Quit" },
|
||||
],
|
||||
@@ -27,9 +27,7 @@ export const HELP_GROUPS: HelpGroup[] = [
|
||||
title: "Search",
|
||||
hints: [
|
||||
{ keys: "/", label: "Edit search" },
|
||||
{ keys: "↵", label: "Run search" },
|
||||
{ keys: "d", label: "Download" },
|
||||
{ keys: "D", label: "Download to a folder…" },
|
||||
{ keys: "d/D", label: "Download (D picks folder)" },
|
||||
{ keys: "s", label: "Sort results" },
|
||||
{ keys: "z", label: "Hide dead torrents" },
|
||||
{ keys: "y", label: "Copy magnet" },
|
||||
@@ -44,7 +42,7 @@ export const HELP_GROUPS: HelpGroup[] = [
|
||||
{ keys: "f", label: "Retry failed" },
|
||||
{ keys: "d", label: "Download again" },
|
||||
{ keys: "e", label: "Open folder" },
|
||||
{ keys: "T", label: "Export .torrent" },
|
||||
{ keys: "s", label: "Export torrent file" },
|
||||
{ keys: "x", label: "Clear recent" },
|
||||
],
|
||||
},
|
||||
@@ -59,7 +57,8 @@ export const HELP_GROUPS: HelpGroup[] = [
|
||||
];
|
||||
|
||||
// Footer labels stay terse so the contextual hint row never wraps; the `?`
|
||||
// overlay (HELP_GROUPS) carries the full, descriptive list.
|
||||
// overlay (HELP_GROUPS) carries the full, descriptive list. Rare or
|
||||
// self-announcing actions (z) stay `?`-only to keep every row inside 80 cols.
|
||||
const NAVIGATE: Hint = { keys: "↑↓←→", label: "Move" };
|
||||
|
||||
const ALWAYS: Hint = { keys: "?", label: "Keys" };
|
||||
@@ -68,7 +67,7 @@ const SWITCH: Hint = { keys: "tab", label: "Switch" };
|
||||
|
||||
const FOLDER: Hint = { keys: "e", label: "Folder" };
|
||||
|
||||
const TORRENT: Hint = { keys: "T", label: "Torrent" };
|
||||
const TORRENT: Hint = { keys: "s", label: "Export" };
|
||||
|
||||
export function footerHints(
|
||||
region: Region,
|
||||
@@ -92,28 +91,13 @@ export function footerHints(
|
||||
}
|
||||
if (section === "downloads") {
|
||||
if (downloadFocus === "paused") {
|
||||
return [
|
||||
{ keys: "p", label: "Resume" },
|
||||
{ keys: "c", label: "Cancel" },
|
||||
FOLDER,
|
||||
TORRENT,
|
||||
SWITCH,
|
||||
ALWAYS,
|
||||
];
|
||||
return [{ keys: "p", label: "Resume" }, { keys: "c", label: "Cancel" }, FOLDER, TORRENT, SWITCH, ALWAYS];
|
||||
}
|
||||
if (downloadFocus === "failed") {
|
||||
return [
|
||||
{ keys: "f", label: "Retry" },
|
||||
{ keys: "c", label: "Remove" },
|
||||
FOLDER,
|
||||
TORRENT,
|
||||
SWITCH,
|
||||
ALWAYS,
|
||||
];
|
||||
return [{ keys: "f", label: "Retry" }, { keys: "c", label: "Remove" }, FOLDER, TORRENT, SWITCH, ALWAYS];
|
||||
}
|
||||
if (downloadFocus === "recent") {
|
||||
return [
|
||||
NAVIGATE,
|
||||
{ keys: "d", label: "Redownload" },
|
||||
{ keys: "c", label: "Remove" },
|
||||
{ keys: "x", label: "Clear" },
|
||||
@@ -123,23 +107,15 @@ export function footerHints(
|
||||
ALWAYS,
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ keys: "p", label: "Pause" },
|
||||
{ keys: "c", label: "Cancel" },
|
||||
FOLDER,
|
||||
TORRENT,
|
||||
SWITCH,
|
||||
ALWAYS,
|
||||
];
|
||||
return [{ keys: "p", label: "Pause" }, { keys: "c", label: "Cancel" }, FOLDER, TORRENT, SWITCH, ALWAYS];
|
||||
}
|
||||
return [
|
||||
NAVIGATE,
|
||||
// d downloads to the default folder, D asks where; the `?` sheet spells
|
||||
// out the difference, the footer just shows both keys exist.
|
||||
{ keys: "d/D", label: "Download" },
|
||||
// The footer advertises only the default download key; D (download to a
|
||||
// chosen folder) stays bound but lives in the `?` sheet alone.
|
||||
{ keys: "d", label: "Download" },
|
||||
{ keys: "y", label: "Copy" },
|
||||
{ keys: "s", label: "Sort" },
|
||||
{ keys: "z", label: "Alive" },
|
||||
{ keys: "/", label: "Search" },
|
||||
SWITCH,
|
||||
ALWAYS,
|
||||
|
||||
@@ -67,6 +67,9 @@ export interface Store {
|
||||
}) => void;
|
||||
copyMagnet: (input: { name: string; magnet: string }) => void;
|
||||
openDownloadFolder: (dir: string) => void;
|
||||
// Copies the cached .torrent metadata into the item's download folder and
|
||||
// reports the outcome through the notice line.
|
||||
exportTorrent: (input: { id: string; name: string }) => void;
|
||||
|
||||
notice: string | null;
|
||||
setNotice: (s: string | null) => void;
|
||||
|
||||
Reference in New Issue
Block a user