mirror of
https://github.com/baairon/torlink.git
synced 2026-07-08 18:28:22 +02:00
feat: seed downloads by default (opt-out) with robust resume and clean quit
Finished downloads now keep seeding automatically and you opt out per item with p, reusing the live torrent instead of tearing it down. Resume and restore seed from the .torrent metadata captured at download time, so they verify the local file immediately rather than depending on re-fetching metadata from the swarm. Quitting now flushes queue, history, and seeds synchronously and exits immediately, fixing the hang when quitting while seeding or with a paused seed. Paused seeds persist their state and stay paused across restarts. Hide the hardware cursor on the splash, and clarify that c removes a finished item the same way in Downloads and Seeding. Tests are isolated from the real data dir via a TORLINK_STATE_DIR override plus vitest config, so runs never touch user data. Adds scripts/verify-seeding.ts (npm run verify:seeding), an offline-deterministic harness proving seed, pause, and resume transfer real bytes.
This commit is contained in:
+19
-4
@@ -3,7 +3,7 @@ import { Box, Text, useApp, useInput, useStdout, useStdin } from "ink";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { loadConfig, saveConfig, type Config } from "../config/config";
|
||||
import { DownloadQueue } from "../download/queue";
|
||||
import { loadQueue } from "../download/persist";
|
||||
import { loadQueue, loadSeeds } from "../download/persist";
|
||||
import { loadHistory } from "../download/history";
|
||||
import { reconcileQueue } from "../download/reconcile";
|
||||
import { parseMagnet } from "../sources/magnet";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type DownloadFocus,
|
||||
type Region,
|
||||
type Section,
|
||||
type SeedFocus,
|
||||
type Store,
|
||||
type View,
|
||||
} from "./store";
|
||||
@@ -26,6 +27,7 @@ import { Footer } from "./components/Footer";
|
||||
import { HelpOverlay } from "./components/HelpOverlay";
|
||||
import { Results } from "./components/Results";
|
||||
import { Downloads } from "./components/Downloads";
|
||||
import { Seeding } from "./components/Seeding";
|
||||
import { Spinner } from "./components/Spinner";
|
||||
import { TabTitle } from "./components/TabTitle";
|
||||
import { Splash } from "./views/Splash";
|
||||
@@ -76,6 +78,7 @@ export function App({
|
||||
const [region, setRegion] = useState<Region>("content");
|
||||
const [captureMode, setCaptureMode] = useState<CaptureMode>("none");
|
||||
const [downloadFocus, setDownloadFocus] = useState<DownloadFocus | null>(null);
|
||||
const [seedFocus, setSeedFocus] = useState<SeedFocus | null>(null);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const booting = useRef(false);
|
||||
@@ -89,6 +92,7 @@ export function App({
|
||||
const q = new DownloadQueue();
|
||||
q.restore(reconcileQueue(await loadQueue()));
|
||||
q.restoreHistory(await loadHistory());
|
||||
q.restoreSeeds(await loadSeeds());
|
||||
if (!alive) {
|
||||
q.suspend();
|
||||
return;
|
||||
@@ -134,7 +138,9 @@ export function App({
|
||||
);
|
||||
|
||||
const quitAll = useCallback(() => {
|
||||
queue?.suspend();
|
||||
// Flush all state synchronously up front so nothing is lost to the hard
|
||||
// exit; the unmount effect still runs suspend() for the engine teardown.
|
||||
queue?.persistSync();
|
||||
if (onQuit) onQuit();
|
||||
else exit();
|
||||
}, [queue, onQuit, exit]);
|
||||
@@ -238,6 +244,8 @@ export function App({
|
||||
setCaptureMode,
|
||||
downloadFocus,
|
||||
setDownloadFocus,
|
||||
seedFocus,
|
||||
setSeedFocus,
|
||||
startDownload,
|
||||
notice,
|
||||
setNotice,
|
||||
@@ -259,6 +267,7 @@ export function App({
|
||||
showHelp,
|
||||
captureMode,
|
||||
downloadFocus,
|
||||
seedFocus,
|
||||
startDownload,
|
||||
notice,
|
||||
listRows,
|
||||
@@ -351,13 +360,19 @@ export function App({
|
||||
>
|
||||
<Sidebar />
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
{section === "downloads" ? <Downloads /> : <Results />}
|
||||
{section === "downloads" ? (
|
||||
<Downloads />
|
||||
) : section === "seeding" ? (
|
||||
<Seeding />
|
||||
) : (
|
||||
<Results />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{showFooter ? (
|
||||
<Box display={showHelp ? "none" : "flex"}>
|
||||
<Footer hints={footerHints(region, section, downloadFocus)} />
|
||||
<Footer hints={footerHints(region, section, downloadFocus, seedFocus)} />
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { useStore, useQueueHistory, useSeeds, type SeedFocus } from "../store";
|
||||
import { Panel } from "./Panel";
|
||||
import { wrapStep, windowStart } from "../move";
|
||||
import { COLOR, GUTTER, ICON, SOURCE_STYLE } from "../theme";
|
||||
import { cleanText, formatBytes, formatBytesPerSec, truncate } from "../../util/format";
|
||||
import type { SeedItem } from "../../download/types";
|
||||
|
||||
const MARK = 2;
|
||||
const SIZE_W = 10;
|
||||
const STATUS_W = 14;
|
||||
const SRC_W = 4;
|
||||
const PAUSED = "#7c7785";
|
||||
|
||||
function glyph(seed: SeedItem | undefined): { icon: string; color: string } {
|
||||
if (!seed) return { icon: ICON.done, color: COLOR.good };
|
||||
if (seed.status === "seeding") return { icon: ICON.up, color: COLOR.good };
|
||||
if (seed.status === "paused") return { icon: ICON.pause, color: PAUSED };
|
||||
return { icon: ICON.warn, color: COLOR.warn };
|
||||
}
|
||||
|
||||
function statusCell(seed: SeedItem | undefined): { text: string; color?: string; dim: boolean } {
|
||||
if (!seed) return { text: "ready", dim: true };
|
||||
if (seed.status === "seeding") {
|
||||
return { text: `${ICON.up}${formatBytesPerSec(seed.uploadSpeed) || "0 B/s"} ${ICON.peer}${seed.peers}`, color: COLOR.good, dim: false };
|
||||
}
|
||||
if (seed.status === "paused") return { text: "paused", dim: true };
|
||||
return { text: "file gone", color: COLOR.warn, dim: false };
|
||||
}
|
||||
|
||||
export function Seeding() {
|
||||
const { queue, region, contentWidth, listRows, setNotice, setSeedFocus } = useStore();
|
||||
const history = useQueueHistory(queue);
|
||||
const seeds = useSeeds(queue);
|
||||
const focused = region === "content";
|
||||
|
||||
const total = history.length;
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const clamped = Math.min(cursor, Math.max(0, total - 1));
|
||||
|
||||
const focusStatus: SeedFocus | null =
|
||||
focused && total > 0 ? (seeds.get(history[clamped]?.id ?? "")?.status ?? "idle") : null;
|
||||
useEffect(() => {
|
||||
setSeedFocus(focusStatus);
|
||||
return () => setSeedFocus(null);
|
||||
}, [focusStatus, setSeedFocus]);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (key.upArrow) setCursor(wrapStep(clamped, -1, total));
|
||||
else if (key.downArrow) setCursor(wrapStep(clamped, 1, total));
|
||||
else if (input === "p") {
|
||||
const h = history[clamped];
|
||||
if (!h) return;
|
||||
queue.toggleSeeding(h);
|
||||
if (queue.getSeed(h.id)?.status === "missing") {
|
||||
setNotice(`${ICON.warn} That file isn't on disk anymore.`);
|
||||
}
|
||||
} else if (input === "c") {
|
||||
const h = history[clamped];
|
||||
if (h) queue.removeHistory(h.id);
|
||||
}
|
||||
},
|
||||
{ isActive: focused && total > 0 },
|
||||
);
|
||||
|
||||
const panelH = Math.max(5, listRows - 1);
|
||||
const seedingCount = queue.seedingCount;
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<Panel title="seeding" width={contentWidth} focused={focused} height={panelH}>
|
||||
<Text dimColor>Nothing here yet. Downloads start seeding automatically when they finish, and show up here.</Text>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary line: live totals across active seeds, or an invite to start.
|
||||
let totalUp = 0;
|
||||
let totalPeers = 0;
|
||||
let totalShared = 0;
|
||||
for (const s of seeds.values()) {
|
||||
totalShared += s.uploaded;
|
||||
if (s.status === "seeding") {
|
||||
totalUp += s.uploadSpeed;
|
||||
totalPeers += s.peers;
|
||||
}
|
||||
}
|
||||
|
||||
const rows = Math.max(1, panelH - 2);
|
||||
const start = windowStart(clamped, total, rows);
|
||||
const visible = history.slice(start, start + rows);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="seeding"
|
||||
width={contentWidth}
|
||||
focused={focused}
|
||||
count={seedingCount > 0 ? `(${seedingCount})` : undefined}
|
||||
height={panelH}
|
||||
>
|
||||
<Box>
|
||||
{seedingCount > 0 ? (
|
||||
<Text color={COLOR.good}>
|
||||
{ICON.up} {formatBytesPerSec(totalUp) || "0 B/s"}
|
||||
<Text dimColor>{` ${ICON.dot} ${totalPeers} peers ${ICON.dot} ${formatBytes(totalShared)} shared back`}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text dimColor>Downloads seed automatically when they finish. Press p to pause or resume any of them.</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Box width={MARK} flexShrink={0} />
|
||||
<Box width={GUTTER} flexShrink={0} />
|
||||
<Box flexGrow={1} minWidth={0} marginLeft={1}>
|
||||
<Text bold dimColor>Name</Text>
|
||||
</Box>
|
||||
<Box width={SIZE_W} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text bold dimColor>Size</Text>
|
||||
</Box>
|
||||
<Box width={STATUS_W} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text bold dimColor>Status</Text>
|
||||
</Box>
|
||||
<Box width={SRC_W} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text bold dimColor>Src</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{visible.map((h, i) => {
|
||||
const here = start + i === clamped && focused;
|
||||
const seed = seeds.get(h.id);
|
||||
const g = glyph(seed);
|
||||
const st = statusCell(seed);
|
||||
const ss = SOURCE_STYLE[h.source ?? "fitgirl"];
|
||||
return (
|
||||
<Box key={h.id}>
|
||||
<Box width={MARK} flexShrink={0}>
|
||||
<Text color={COLOR.accent} bold>{here ? ICON.pointer : ""}</Text>
|
||||
</Box>
|
||||
<Box width={GUTTER} flexShrink={0}>
|
||||
<Text color={g.color} dimColor={!seed && !here}>{g.icon}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth={0} marginLeft={1}>
|
||||
<Text wrap="truncate-end" bold={here} color={here ? COLOR.accent : undefined} dimColor={!here}>
|
||||
{cleanText(h.name)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={SIZE_W} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text dimColor>{h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-"}</Text>
|
||||
</Box>
|
||||
<Box width={STATUS_W} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text color={st.color} dimColor={st.dim}>{truncate(st.text, STATUS_W)}</Text>
|
||||
</Box>
|
||||
<Box width={SRC_W} flexShrink={0} marginLeft={1} justifyContent="flex-end">
|
||||
<Text color={h.source ? ss.color : undefined} dimColor={!h.source || !here}>
|
||||
{h.source ? ss.tag : "mag"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,12 @@ const FILTERS: NavItem[] = CATEGORIES.map((c) => ({
|
||||
key: c.key as Section,
|
||||
label: c.label,
|
||||
}));
|
||||
const LIBRARY: NavItem[] = [{ key: "downloads", label: "Downloads" }];
|
||||
const LIBRARY: NavItem[] = [
|
||||
{ key: "downloads", label: "Downloads" },
|
||||
{ key: "seeding", label: "Seeding" },
|
||||
];
|
||||
|
||||
const BADGED = (key: Section): boolean => key === "downloads" || key === "seeding";
|
||||
|
||||
const GROUPS: NavItem[][] = [FILTERS, LIBRARY];
|
||||
|
||||
@@ -21,8 +26,7 @@ const NAV: NavItem[] = GROUPS.flat();
|
||||
const BADGE_W = " (00)".length;
|
||||
|
||||
export const RAIL_WIDTH =
|
||||
GUTTER +
|
||||
Math.max(...NAV.map((n) => n.label.length + (n.key === "downloads" ? BADGE_W : 0)));
|
||||
GUTTER + Math.max(...NAV.map((n) => n.label.length + (BADGED(n.key) ? BADGE_W : 0)));
|
||||
|
||||
export function Sidebar() {
|
||||
const { section, setSection, region, setRegion, queue } = useStore();
|
||||
@@ -30,6 +34,7 @@ export function Sidebar() {
|
||||
const idx = Math.max(0, NAV.findIndex((n) => n.key === section));
|
||||
useQueueItems(queue);
|
||||
const active = queue.activeCount;
|
||||
const seeding = queue.seedingCount;
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
@@ -62,11 +67,14 @@ export function Sidebar() {
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.key === "downloads" && active > 0 ? (
|
||||
<Box flexShrink={0}>
|
||||
<Text dimColor>{` (${active})`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{(() => {
|
||||
const n = item.key === "downloads" ? active : item.key === "seeding" ? seeding : 0;
|
||||
return n > 0 ? (
|
||||
<Box flexShrink={0}>
|
||||
<Text dimColor>{` (${n})`}</Text>
|
||||
</Box>
|
||||
) : null;
|
||||
})()}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
+15
-2
@@ -1,4 +1,4 @@
|
||||
import type { DownloadFocus, Region, Section } from "./store";
|
||||
import type { DownloadFocus, Region, Section, SeedFocus } from "./store";
|
||||
|
||||
export interface Hint {
|
||||
keys: string;
|
||||
@@ -33,12 +33,19 @@ export const HELP_GROUPS: HelpGroup[] = [
|
||||
title: "Downloads",
|
||||
hints: [
|
||||
{ keys: "p", label: "Pause/resume" },
|
||||
{ keys: "c", label: "Cancel/remove" },
|
||||
{ keys: "c", label: "Cancel active, else remove" },
|
||||
{ keys: "f", label: "Retry failed" },
|
||||
{ keys: "d", label: "Download again" },
|
||||
{ keys: "x", label: "Clear recent" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Seeding",
|
||||
hints: [
|
||||
{ keys: "p", label: "Pause/resume" },
|
||||
{ keys: "c", label: "Remove (same as Downloads)" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const ALWAYS: Hint = { keys: "?", label: "Keys" };
|
||||
@@ -49,6 +56,7 @@ export function footerHints(
|
||||
region: Region,
|
||||
section: Section,
|
||||
downloadFocus?: DownloadFocus | null,
|
||||
seedFocus?: SeedFocus | null,
|
||||
): Hint[] {
|
||||
if (region === "sidebar") {
|
||||
return [
|
||||
@@ -59,6 +67,11 @@ export function footerHints(
|
||||
{ keys: "q", label: "Quit" },
|
||||
];
|
||||
}
|
||||
if (section === "seeding") {
|
||||
const label =
|
||||
seedFocus === "seeding" ? "Pause" : seedFocus === "missing" ? "Retry" : "Resume";
|
||||
return [{ keys: "p", label }, { keys: "c", label: "Remove" }, SWITCH, ALWAYS];
|
||||
}
|
||||
if (section === "downloads") {
|
||||
if (downloadFocus === "paused") {
|
||||
return [{ keys: "p", label: "Resume" }, { keys: "c", label: "Cancel" }, SWITCH, ALWAYS];
|
||||
|
||||
+29
-2
@@ -2,14 +2,14 @@ import { createContext, useContext, useEffect, useState } from "react";
|
||||
import type { Config } from "../config/config";
|
||||
import type { DownloadQueue } from "../download/queue";
|
||||
import type { HistoryItem } from "../download/history";
|
||||
import type { QueueItem } from "../download/types";
|
||||
import type { QueueItem, SeedItem } from "../download/types";
|
||||
import type { SourceGroup, SourceId } from "../sources/types";
|
||||
|
||||
export type View = "splash" | "browser";
|
||||
|
||||
export type Category = "all" | "games" | "movies" | "tv" | "anime";
|
||||
|
||||
export type Section = Category | "downloads";
|
||||
export type Section = Category | "downloads" | "seeding";
|
||||
|
||||
export const CATEGORIES: { key: Category; label: string; group?: SourceGroup }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
@@ -25,6 +25,8 @@ export type CaptureMode = "none" | "text" | "esc";
|
||||
|
||||
export type DownloadFocus = "downloading" | "paused" | "failed" | "recent";
|
||||
|
||||
export type SeedFocus = "seeding" | "paused" | "missing" | "idle";
|
||||
|
||||
export interface Store {
|
||||
config: Config;
|
||||
setConfig: (c: Config) => void;
|
||||
@@ -44,6 +46,8 @@ export interface Store {
|
||||
|
||||
downloadFocus: DownloadFocus | null;
|
||||
setDownloadFocus: (f: DownloadFocus | null) => void;
|
||||
seedFocus: SeedFocus | null;
|
||||
setSeedFocus: (f: SeedFocus | null) => void;
|
||||
|
||||
startDownload: (input: {
|
||||
id: string;
|
||||
@@ -114,3 +118,26 @@ export function useQueueHistory(queue: DownloadQueue): HistoryItem[] {
|
||||
}, [queue]);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useSeeds(queue: DownloadQueue): Map<string, SeedItem> {
|
||||
const [seeds, setSeeds] = useState<Map<string, SeedItem>>(
|
||||
() => new Map(queue.getSeeds().map((s) => [s.id, s])),
|
||||
);
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const onUpdate = (): void => {
|
||||
if (timer) return;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
setSeeds(new Map(queue.getSeeds().map((s) => [s.id, s])));
|
||||
}, 200);
|
||||
};
|
||||
queue.on("update", onUpdate);
|
||||
onUpdate();
|
||||
return () => {
|
||||
queue.off("update", onUpdate);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [queue]);
|
||||
return seeds;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const ICON = {
|
||||
warn: "⚠",
|
||||
bar: "▌",
|
||||
down: "↓",
|
||||
up: "↑",
|
||||
peer: "•",
|
||||
pause: "⏸",
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user