Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ keypresses, nothing to memorize, and `?` brings up the full list anytime.
<img src="preview/help.svg" alt="torlink's keyboard help overlay: every shortcut grouped by what it does" style="max-width: 832px; width: 100%; height: auto;">
</p>

When a download comes with several videos or tracks, torlink drops a `playlist.m3u` into each folder holding more than one, so a course split into modules plays straight through in order. Run `torlnk --no-playlist` if you'd rather it didn't.

## What it searches

A short, hand-picked list of trusted sources:
Expand Down Expand Up @@ -1090,6 +1092,12 @@ It turns the folder into a torrent, saves `album.torrent` next to it, prints the
POST /add {"magnet":"magnet:?xt=..."}
POST /add {"torrent":"<base64>"}

Either can carry a `seedTime` for that one torrent, in the same grammar as `--seed-time` (`"30d"`, `"2h"`; `0` means never stop). It wins over the daemon-wide flag, so a box that normally drops seeds after a couple of hours can keep one release alive for a month. Change it later, or on something already downloading, through the control endpoint:

POST /control {"id":"<info hash>","action":"seed-time","seedTime":"30d"}

`GET /downloads` reports the limit and when it falls due (`seedUntil`) on every torrent that has one.

### In a container

For a seedbox, a NAS, or anywhere you'd rather not install Node, there's a `Dockerfile` and a
Expand Down
16 changes: 16 additions & 0 deletions src/cli/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@ import { describe, it, expect } from "vitest";
import { HELP_TEXT, parseCliArgs } from "./args";

describe("parseCliArgs", () => {
it.each([
[],
["magnet:?xt=urn:btih:abc"],
["./course.torrent"],
["watch", "/srv/incoming", "--to", "/srv/downloads", "--daemon"],
["serve", "--port", "9161", "--daemon"],
])("allows --no-playlist before or after download arguments: %j", (...args) => {
const expected = { ...parseCliArgs(args), playlist: false };
expect(parseCliArgs(["--no-playlist", ...args])).toEqual(expected);
expect(parseCliArgs([...args, "--no-playlist"])).toEqual(expected);
});
it("rejects --no-playlist for commands that do not create playlists", () => {
expect(parseCliArgs(["files", "--no-playlist"]).kind).toBe("invalid");
expect(parseCliArgs(["attach", "--no-playlist"]).kind).toBe("invalid");
expect(parseCliArgs(["seed", "./course", "--no-playlist"]).kind).toBe("invalid");
});
it("defaults to run with no args", () => {
expect(parseCliArgs([])).toEqual({ kind: "run", web: false });
});
Expand Down
28 changes: 28 additions & 0 deletions src/cli/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type CliCommand =
kind: "run";
initialMagnet?: string;
initialTorrent?: string;
playlist?: boolean;
/** Host the browser dashboard in-process, sharing the TUI's queue. */
web?: boolean;
/** The interface, port and token the in-process dashboard binds. */
Expand All @@ -20,6 +21,7 @@ export type CliCommand =
}
| {
kind: "watch";
playlist?: boolean;
dir: string;
downloadDir?: string;
seedTimeMs?: number;
Expand All @@ -28,6 +30,7 @@ export type CliCommand =
}
| {
kind: "serve";
playlist?: boolean;
port?: number;
host?: string;
token?: string;
Expand Down Expand Up @@ -145,6 +148,16 @@ function seedTimeFrom(raw: string | undefined): number | undefined {
}

export function parseCliArgs(argv: string[]): CliCommand {
const noPlaylist = argv.includes("--no-playlist");
const cmd = parseCommand(argv.filter((arg) => arg !== "--no-playlist"));
if (!noPlaylist || cmd.kind === "invalid" || cmd.kind === "help" || cmd.kind === "version") return cmd;
if (cmd.kind === "run" || cmd.kind === "watch" || cmd.kind === "serve") {
return { ...cmd, playlist: false };
}
return { kind: "invalid", arg: "--no-playlist (use with the TUI, watch, or serve)" };
}

function parseCommand(argv: string[]): CliCommand {
const args = argv.filter((a) => a.trim() !== "");
// No arguments: the plain TUI. Routed through parseRun so the "run" shape has
// exactly one producer — a hand-written `{ kind: "run" }` here would drift
Expand Down Expand Up @@ -325,6 +338,12 @@ flags, one name per thing
A bare directory argument is always the folder a command operates on
(watch <dir>, files [dir]); --to is always where output goes.

playlists (TUI/watch/serve): finished downloads automatically get a
playlist.m3u in each folder containing 2+ audio/video files, including nested
folders, in natural filename order. Single-file folders are skipped and
existing playlists are kept. Pass --no-playlist (or set TORLINK_NO_PLAYLIST=1)
to disable creation; existing playlists remain on disk.

watch mode (no TUI): drop a .torrent, or a .magnet/.txt holding a magnet or
info hash, into <dir> and it downloads then seeds. Add --to <dir> to choose
where files land. Handled files move to <dir>/.processed (or /.failed).
Expand All @@ -337,6 +356,9 @@ anyone the magnet and they pull the files from you. Takes --seed-time,
seed expiry (seed/watch/serve): --seed-time <dur> stops seeding a torrent that long
after it finishes (e.g. 1h, 30m, 90s, 2d); files are kept by default. Add
--delete-files to also remove the downloaded data when the timer expires.
One torrent can carry its own limit over the serve API (seedTime on /add, or
the seed-time control action); that wins over --seed-time, and 0 keeps it
seeding for good.

--daemon (watch/serve/files): background the process (own session, logs to a
file), so you can log out and it keeps running. Prints the pid and log path.
Expand All @@ -347,7 +369,13 @@ left off. Downloads and seeds keep running while detached.

serve mode (no TUI): a small HTTP API for handing torlink a magnet.
POST /add {"magnet":"..."} queue a magnet or info hash
... "seedTime":"30d" optional: this torrent's own seed limit
(0 = never stop); overrides --seed-time
POST /add {"torrent":"<b64>"} queue an uploaded .torrent (base64 or data: URI)
POST /control {"id":"...","action":"seed-time","seedTime":"30d"}
change a torrent's seed limit ("" = inherit);
other actions: pause, resume, start-seed,
stop-seed, remove, delete
GET /downloads list active downloads and seeds
GET /health liveness (no auth)
flags: --port <n> (default 9161), --host <addr>, --token <secret> (required
Expand Down
1 change: 1 addition & 0 deletions src/daemon/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe("contentType", () => {
expect(contentType("Movie.mp4")).toBe("video/mp4");
expect(contentType("track.MP3")).toBe("audio/mpeg");
expect(contentType("clip.mkv")).toBe("video/x-matroska");
expect(contentType("playlist.m3u")).toBe("audio/x-mpegurl; charset=utf-8");
});
it("falls back to octet-stream", () => {
expect(contentType("archive.xyz")).toBe("application/octet-stream");
Expand Down
1 change: 1 addition & 0 deletions src/daemon/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { LOOPBACK_HOSTS, isAuthorized, hostHeaderOk } from "./auth";
export const DEFAULT_FILES_PORT = 9160;

const MIME: Record<string, string> = {
".m3u": "audio/x-mpegurl; charset=utf-8",
".mp4": "video/mp4",
".m4v": "video/mp4",
".mkv": "video/x-matroska",
Expand Down
8 changes: 6 additions & 2 deletions src/daemon/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ export function policySummary(cfg: Config): string {
// Build a queue and restore persisted state, matching the TUI's boot order
// (history before seeds — seeds resolve against history). `downloadDir` falls
// back to the saved config's dir when the caller doesn't override it.
export async function startRuntime(overrideDir?: string): Promise<Runtime> {
export async function startRuntime(overrideDir?: string, options: { playlist?: boolean } = {}): Promise<Runtime> {
const cfg = await loadConfig();
const queue = new DownloadQueue();
const queue = new DownloadQueue({ playlist: options.playlist });
queue.setTrackers(cfg.trackers);
// Everything below matches App.tsx's boot: without it a headless run ignores
// the configured transfer limits, never auto-stops a seed, and fails a
Expand Down Expand Up @@ -150,6 +150,9 @@ export interface AddInputOptions {
debridProvider?: DebridProviderId;
/** Total size in bytes when the caller knows it; seeds the row's progress total. */
sizeBytes?: number;
// Per-torrent seed limit (ms after completion; 0 = never stop). Unset
// inherits the daemon-wide --seed-time.
seedTimeMs?: number;
}

export async function addInput(
Expand All @@ -173,6 +176,7 @@ export async function addInput(
name: options.name?.trim() || parsed.name,
magnet: parsed.magnet,
...(options.sizeBytes !== undefined ? { sizeBytes: options.sizeBytes } : {}),
...(options.seedTimeMs !== undefined ? { seedTimeMs: options.seedTimeMs } : {}),
};
if (options.debridToken) {
// Not awaited, exactly as the TUI does it: addDebrid's promise resolves
Expand Down
51 changes: 49 additions & 2 deletions src/daemon/seed-reaper.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { describe, it, expect } from "vitest";
import { dueSeeds, type ReapableQueue } from "./seed-reaper";
import { dueSeeds, seedLimitFor, type ReapableQueue } from "./seed-reaper";

const HOUR = 3_600_000;

function queue(
seeds: { id: string; name: string; dir: string; status: string }[],
history: { id: string; completedAt: number }[],
history: { id: string; completedAt: number; seedTimeMs?: number }[],
): ReapableQueue {
return {
getSeeds: () => seeds,
Expand Down Expand Up @@ -52,3 +52,50 @@ describe("dueSeeds", () => {
expect(dueSeeds(q, HOUR, now)).toEqual([{ id: "a", name: "Movie", dir: "/downloads" }]);
});
});

describe("dueSeeds with per-torrent limits", () => {
const now = 100 * HOUR;
const seeding = (id: string) => ({ id, name: id, dir: "/d", status: "seeding" });

it("a torrent's own limit wins over the daemon-wide one, in both directions", () => {
const q = queue(
[seeding("longer"), seeding("shorter"), seeding("inherits")],
[
{ id: "longer", completedAt: now - 5 * HOUR, seedTimeMs: 24 * HOUR },
{ id: "shorter", completedAt: now - 5 * HOUR, seedTimeMs: HOUR },
{ id: "inherits", completedAt: now - 5 * HOUR },
],
);
// Daemon says 2h: "longer" (24h) is kept, "shorter" (1h) and the
// inheriting one (2h) are due.
expect(dueSeeds(q, 2 * HOUR, now).map((s) => s.id)).toEqual(["shorter", "inherits"]);
});

it("acts on a torrent's own limit even when the daemon has none", () => {
const q = queue(
[seeding("capped"), seeding("free")],
[
{ id: "capped", completedAt: now - 3 * HOUR, seedTimeMs: HOUR },
{ id: "free", completedAt: 0 },
],
);
expect(dueSeeds(q, 0, now).map((s) => s.id)).toEqual(["capped"]);
});

it("0 on the torrent means never stop it, whatever the daemon says", () => {
const q = queue([seeding("forever")], [{ id: "forever", completedAt: 0, seedTimeMs: 0 }]);
expect(dueSeeds(q, HOUR, now)).toEqual([]);
});

it("with no limit anywhere nothing is ever due", () => {
const q = queue([seeding("a")], [{ id: "a", completedAt: 0 }]);
expect(dueSeeds(q, 0, now)).toEqual([]);
});

it("seedLimitFor prefers the torrent's own value, including 0", () => {
expect(seedLimitFor(undefined, HOUR)).toBe(HOUR);
expect(seedLimitFor(2 * HOUR, HOUR)).toBe(2 * HOUR);
expect(seedLimitFor(0, HOUR)).toBe(0);
expect(seedLimitFor(undefined, 0)).toBe(0);
});
});
26 changes: 21 additions & 5 deletions src/daemon/seed-reaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
//
// The clock is the download's completion time (history.completedAt), not when
// this process started, so a restart doesn't reset every torrent's timer.
//
// A torrent can carry its own limit (history.seedTimeMs, set over the headless
// API): that wins over the daemon-wide value, and 0 there means "never stop
// this one". With neither set the seed is left alone, so the reaper is safe to
// run even when the daemon has no --seed-time.

import type { DownloadQueue } from "../download/queue";
import { deleteSeedData } from "../download/delete-data";
Expand All @@ -18,7 +23,7 @@ const DEFAULT_CHECK_MS = 30_000;
// The slice of DownloadQueue the reaper needs — keeps it trivially testable.
export interface ReapableQueue {
getSeeds(): { id: string; name: string; dir: string; status: string }[];
getHistory(): { id: string; completedAt: number }[];
getHistory(): { id: string; completedAt: number; seedTimeMs?: number }[];
stopSeeding(id: string): void;
}

Expand All @@ -28,14 +33,25 @@ export interface DueSeed {
dir: string;
}

// The actively-seeding torrents whose completion is older than the limit.
// The effective limit for one torrent: its own if it has one, else the
// daemon-wide value. 0 / undefined means no limit.
export function seedLimitFor(own: number | undefined, daemonWide: number): number {
return own ?? daemonWide;
}

// The actively-seeding torrents whose completion is older than their limit.
// `seedTimeMs` is the daemon-wide default (0 = none); a torrent's own
// history.seedTimeMs overrides it.
export function dueSeeds(queue: ReapableQueue, seedTimeMs: number, now: number): DueSeed[] {
const completedAt = new Map(queue.getHistory().map((h) => [h.id, h.completedAt]));
const history = new Map(queue.getHistory().map((h) => [h.id, h]));
const out: DueSeed[] = [];
for (const s of queue.getSeeds()) {
if (s.status !== "seeding") continue;
const since = completedAt.get(s.id) ?? now; // unknown completion → treat as just finished
if (now - since >= seedTimeMs) out.push({ id: s.id, name: s.name, dir: s.dir });
const h = history.get(s.id);
const limit = seedLimitFor(h?.seedTimeMs, seedTimeMs);
if (!(limit > 0)) continue;
const since = h?.completedAt ?? now; // unknown completion → treat as just finished
if (now - since >= limit) out.push({ id: s.id, name: s.name, dir: s.dir });
}
return out;
}
Expand Down
4 changes: 3 additions & 1 deletion src/daemon/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ export async function runSeed(target: string, options: SeedOptions = {}): Promis

// The download dir is the content's parent, not the configured one: this
// torrent's data is already where it is, and moving it is not on offer.
const runtime = await startRuntime(root);
// No playlists either: the folder is the user's own, and a playlist.m3u left
// in it would be hashed into the next run's torrent and change the magnet.
const runtime = await startRuntime(root, { playlist: false });
const outcome = await addInput(runtime, created.torrentPath, { allowTorrentPath: true });
if (outcome === "invalid") throw new Error(`could not seed ${created.torrentPath}`);
if (outcome === "duplicate") log("already in the queue — leaving it alone");
Expand Down
Loading