From 9ea819941ba96324850b10944a4bffac9f056a75 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 08:14:20 -0700 Subject: [PATCH 1/5] feat(serve): per-torrent seed time over the API (#186) --- README.md | 6 ++ src/cli/args.ts | 9 +++ src/daemon/runtime.ts | 10 ++- src/daemon/seed-reaper.test.ts | 51 ++++++++++++- src/daemon/seed-reaper.ts | 26 +++++-- src/daemon/serve.test.ts | 132 ++++++++++++++++++++++++++++++++- src/daemon/serve.ts | 93 ++++++++++++++++++----- src/daemon/watch.ts | 6 +- src/download/history.ts | 4 + src/download/queue.test.ts | 42 +++++++++++ src/download/queue.ts | 30 ++++++++ src/download/types.ts | 2 + 12 files changed, 381 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index c608f88e..3e6717b8 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,12 @@ It turns the folder into a torrent, saves `album.torrent` next to it, prints the POST /add {"magnet":"magnet:?xt=..."} POST /add {"torrent":""} +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":"","action":"seed-time","seedTime":"30d"} + +`GET /downloads` reports the limit and when it falls due (`seedUntil`) on every torrent that has one. + ## Contributing To run or work on torlink locally: diff --git a/src/cli/args.ts b/src/cli/args.ts index 7ed431be..790b393f 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -201,6 +201,9 @@ anyone the magnet and they pull the files from you. Takes --seed-time, seed expiry (seed/watch/serve): --seed-time 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. @@ -211,7 +214,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":""} 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 (default 9161), --host (default 127.0.0.1), diff --git a/src/daemon/runtime.ts b/src/daemon/runtime.ts index 9ae9385c..4cc3fef9 100644 --- a/src/daemon/runtime.ts +++ b/src/daemon/runtime.ts @@ -61,6 +61,9 @@ export interface AddInputOptions { // the watch folder opts in; a network caller (the HTTP add API) must never // be able to point the daemon at the local filesystem. allowTorrentPath?: boolean; + // Per-torrent seed limit (ms after completion; 0 = never stop). Unset + // inherits the daemon-wide --seed-time. + seedTimeMs?: number; } export async function addInput( @@ -80,7 +83,12 @@ export async function addInput( if (runtime.queue.has(parsed.infoHash)) return "duplicate"; await fs.mkdir(runtime.downloadDir, { recursive: true }).catch(() => {}); runtime.queue.add( - { id: parsed.infoHash, name: parsed.name, magnet: parsed.magnet }, + { + id: parsed.infoHash, + name: parsed.name, + magnet: parsed.magnet, + ...(options.seedTimeMs !== undefined ? { seedTimeMs: options.seedTimeMs } : {}), + }, runtime.downloadDir, ); return "added"; diff --git a/src/daemon/seed-reaper.test.ts b/src/daemon/seed-reaper.test.ts index fc2ed774..063e64e3 100644 --- a/src/daemon/seed-reaper.test.ts +++ b/src/daemon/seed-reaper.test.ts @@ -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, @@ -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); + }); +}); diff --git a/src/daemon/seed-reaper.ts b/src/daemon/seed-reaper.ts index 445fac9c..e5dedd78 100644 --- a/src/daemon/seed-reaper.ts +++ b/src/daemon/seed-reaper.ts @@ -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"; @@ -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; } @@ -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; } diff --git a/src/daemon/serve.test.ts b/src/daemon/serve.test.ts index f4e36637..eaa51d35 100644 --- a/src/daemon/serve.test.ts +++ b/src/daemon/serve.test.ts @@ -2,7 +2,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import os from "node:os"; import path from "node:path"; import { promises as fs } from "node:fs"; -import { handleApi, isAuthorized, extractMagnet, extractTorrentBytes, parseControl, applyControl } from "./serve"; +import { + handleApi, + isAuthorized, + extractMagnet, + extractTorrentBytes, + extractSeedTime, + parseControl, + applyControl, +} from "./serve"; import type { Runtime } from "./runtime"; const HASH = "abcdef0123456789abcdef0123456789abcdef01"; @@ -53,6 +61,7 @@ describe("handleApi", () => { add, getItems: () => [], getSeeds: () => [], + getHistory: () => [], } as unknown as Runtime["queue"], downloadDir: dir, }; @@ -119,6 +128,79 @@ describe("handleApi", () => { expect(res.status).toBe(404); }); + it("forwards a per-torrent seedTime from POST /add", async () => { + const res = await handleApi(runtime, null, "POST", "/add", undefined, `{"magnet":"${MAGNET}","seedTime":"30d"}`); + expect(res.status).toBe(200); + expect(add).toHaveBeenCalledWith( + { id: HASH, name: "Example", magnet: MAGNET, seedTimeMs: 30 * 86_400_000 }, + dir, + ); + }); + + it("adds without a seedTime when the field is absent (daemon default applies)", async () => { + await handleApi(runtime, null, "POST", "/add", undefined, `{"magnet":"${MAGNET}"}`); + const [input] = add.mock.calls[0]!; + expect("seedTimeMs" in (input as object)).toBe(false); + }); + + it("400s an unusable seedTime on POST /add without adding anything", async () => { + const res = await handleApi(runtime, null, "POST", "/add", undefined, `{"magnet":"${MAGNET}","seedTime":"soon"}`); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "invalid seedTime" }); + expect(add).not.toHaveBeenCalled(); + }); + + it("sets a seed limit on a known torrent through POST /control", async () => { + const setSeedTime = vi.fn().mockReturnValue(true); + runtime.queue = { setSeedTime } as unknown as Runtime["queue"]; + const res = await handleApi( + runtime, + null, + "POST", + "/control", + undefined, + `{"id":"${HASH}","action":"seed-time","seedTime":"2h"}`, + ); + expect(res.status).toBe(200); + expect(setSeedTime).toHaveBeenCalledWith(HASH, 2 * 3_600_000); + }); + + it("400s a seed-time control with an unusable value, 404s an unknown torrent", async () => { + const setSeedTime = vi.fn().mockReturnValue(false); + runtime.queue = { setSeedTime } as unknown as Runtime["queue"]; + const bad = await handleApi(runtime, null, "POST", "/control", undefined, `{"id":"${HASH}","action":"seed-time","seedTime":"eventually"}`); + expect(bad.status).toBe(400); + expect(bad.body).toEqual({ error: "invalid seedTime" }); + expect(setSeedTime).not.toHaveBeenCalled(); + const missing = await handleApi(runtime, null, "POST", "/control", undefined, `{"id":"${HASH}","action":"seed-time","seedTime":"1d"}`); + expect(missing.status).toBe(404); + }); + + it("reports a torrent's own limit and its due time on GET /downloads", async () => { + const completedAt = 1_700_000_000_000; + runtime.queue = { + getItems: () => [ + { id: "dl", name: "In flight", status: "downloading", progress: 0.5, peers: 1, speed: 0, seedTimeMs: 60_000 }, + ], + getSeeds: () => [ + { id: "own", name: "Own", status: "seeding", peers: 0, uploaded: 0 }, + { id: "forever", name: "Forever", status: "seeding", peers: 0, uploaded: 0 }, + { id: "plain", name: "Plain", status: "seeding", peers: 0, uploaded: 0 }, + ], + getHistory: () => [ + { id: "own", completedAt, seedTimeMs: 3_600_000 }, + { id: "forever", completedAt, seedTimeMs: 0 }, + { id: "plain", completedAt }, + ], + } as unknown as Runtime["queue"]; + const res = await handleApi(runtime, null, "GET", "/downloads", undefined, ""); + const body = res.body as { downloads: Record[]; seeds: Record[] }; + expect(body.downloads[0]).toMatchObject({ id: "dl", seedTimeMs: 60_000 }); + expect(body.seeds[0]).toMatchObject({ id: "own", seedTimeMs: 3_600_000, seedUntil: completedAt + 3_600_000 }); + expect(body.seeds[1]).toMatchObject({ id: "forever", seedTimeMs: 0, seedUntil: null }); + expect("seedTimeMs" in body.seeds[2]!).toBe(false); + }); + it("pauses a known download on POST /control", async () => { const pause = vi.fn(); runtime.queue = { has: (id: string) => id === HASH, pause } as unknown as Runtime["queue"]; @@ -140,6 +222,12 @@ describe("parseControl", () => { deleteFiles: true, }); }); + it("parses seedTime into ms, blank clears, garbage is null", () => { + expect(parseControl(`{"id":"abc","action":"seed-time","seedTime":"1d"}`)?.seedTimeMs).toBe(86_400_000); + expect(parseControl(`{"id":"abc","action":"seed-time","seedTime":""}`)?.seedTimeMs).toBeUndefined(); + expect(parseControl(`{"id":"abc","action":"seed-time"}`)?.seedTimeMs).toBeUndefined(); + expect(parseControl(`{"id":"abc","action":"seed-time","seedTime":"1 week"}`)?.seedTimeMs).toBeNull(); + }); it("returns null when id or action is missing/blank or the body isn't JSON", () => { expect(parseControl(`{"id":"abc"}`)).toBeNull(); expect(parseControl(`{"action":"pause"}`)).toBeNull(); @@ -185,6 +273,21 @@ describe("applyControl", () => { expect(remove).toHaveBeenCalledWith("z", { deleteFiles: false }); }); + it("seed-time sets, clears, and refuses an unusable value", async () => { + const setSeedTime = vi.fn().mockReturnValue(true); + const rt = mkRuntime({ setSeedTime }); + expect(await applyControl(rt, { id: "s", action: "seed-time", deleteFiles: false, seedTimeMs: 5000 })).toBe("ok"); + expect(setSeedTime).toHaveBeenCalledWith("s", 5000); + expect(await applyControl(rt, { id: "s", action: "seed-time", deleteFiles: false, seedTimeMs: undefined })).toBe("ok"); + expect(setSeedTime).toHaveBeenLastCalledWith("s", undefined); + expect(await applyControl(rt, { id: "s", action: "seed-time", deleteFiles: false, seedTimeMs: null })).toBe( + "invalid-seed-time", + ); + expect(setSeedTime).toHaveBeenCalledTimes(2); + setSeedTime.mockReturnValue(false); + expect(await applyControl(rt, { id: "?", action: "seed-time", deleteFiles: false, seedTimeMs: 1 })).toBe("not-found"); + }); + it("reports not-found when remove finds nothing and unknown-action otherwise", async () => { const rt = mkRuntime({ remove: vi.fn().mockResolvedValue(false) }); expect(await applyControl(rt, { id: "z", action: "remove", deleteFiles: false })).toBe("not-found"); @@ -223,3 +326,30 @@ describe("extractTorrentBytes", () => { expect(extractTorrentBytes("not json")).toBeNull(); }); }); + +describe("extractSeedTime", () => { + it("is undefined when the field is absent, blank, or the body is not JSON", () => { + expect(extractSeedTime(`{"magnet":"m"}`)).toBeUndefined(); + expect(extractSeedTime(`{"magnet":"m","seedTime":""}`)).toBeUndefined(); + expect(extractSeedTime(`{"magnet":"m","seedTime":null}`)).toBeUndefined(); + expect(extractSeedTime("magnet:?xt=urn:btih:abc")).toBeUndefined(); + expect(extractSeedTime("{not json")).toBeUndefined(); + }); + + it("reads the --seed-time grammar, a bare number as seconds, and 0 as never", () => { + expect(extractSeedTime(`{"seedTime":"30d"}`)).toBe(30 * 86_400_000); + expect(extractSeedTime(`{"seedTime":"90m"}`)).toBe(90 * 60_000); + expect(extractSeedTime(`{"seedTime":"45"}`)).toBe(45_000); + expect(extractSeedTime(`{"seedTime":3600}`)).toBe(3_600_000); + expect(extractSeedTime(`{"seedTime":0}`)).toBe(0); + expect(extractSeedTime(`{"seedTime":"0"}`)).toBe(0); + }); + + it("is null for anything it cannot read, so the caller can 400", () => { + expect(extractSeedTime(`{"seedTime":"a month"}`)).toBeNull(); + expect(extractSeedTime(`{"seedTime":"1w"}`)).toBeNull(); + expect(extractSeedTime(`{"seedTime":-5}`)).toBeNull(); + expect(extractSeedTime(`{"seedTime":true}`)).toBeNull(); + expect(extractSeedTime(`{"seedTime":{}}`)).toBeNull(); + }); +}); diff --git a/src/daemon/serve.ts b/src/daemon/serve.ts index d83804d1..6f50e76a 100644 --- a/src/daemon/serve.ts +++ b/src/daemon/serve.ts @@ -12,6 +12,7 @@ import { startRuntime, addInput, type Runtime } from "./runtime"; import { magnetFromTorrentBytes } from "../sources/torrentFile"; import { startSeedReaper } from "./seed-reaper"; import { LOOPBACK_HOSTS, isAuthorized, hostHeaderOk } from "./auth"; +import { parseDuration } from "../util/duration"; import { VERSION } from "../version"; export { isAuthorized } from "./auth"; @@ -92,6 +93,37 @@ export function extractMagnet(bodyText: string): string | null { return raw; } +// The optional per-torrent seed limit on /add and /control: a `seedTime` field +// holding a duration in the --seed-time grammar ("30d", "2h", "90m"; a bare +// number is seconds), or 0 to never stop seeding that torrent. Three answers: +// undefined the field is absent (inherit the daemon-wide --seed-time) +// null it is there but unusable (the caller gets a 400) +// number milliseconds +// A raw (non-JSON) body carries no seedTime. +export function extractSeedTime(bodyText: string): number | null | undefined { + const raw = bodyText.trim(); + if (!raw.startsWith("{")) return undefined; + let obj: Record; + try { + obj = JSON.parse(raw) as Record; + } catch { + return undefined; + } + return seedTimeField(obj.seedTime); +} + +function seedTimeField(value: unknown): number | null | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === "number") { + return Number.isFinite(value) && value >= 0 ? Math.floor(value) * 1000 : null; + } + if (typeof value === "string") { + if (!value.trim()) return undefined; + return parseDuration(value); + } + return null; +} + // Control actions the headless API accepts (POST /control). A seedbox web app // drives per-torrent buttons through these instead of the interactive keymap. export const CONTROL_ACTIONS = [ @@ -101,6 +133,7 @@ export const CONTROL_ACTIONS = [ "stop-seed", // stop seeding but keep the files "remove", // forget the torrent, keep files on disk "delete", // forget the torrent AND delete its files + "seed-time", // set this torrent's own seed limit ({ seedTime }); "" clears it ] as const; export type ControlAction = (typeof CONTROL_ACTIONS)[number]; @@ -108,11 +141,14 @@ export interface ControlRequest { id: string; action: string; deleteFiles: boolean; + // Parsed `seedTime` for the seed-time action: ms, undefined when absent or + // blank (clear the override), null when present but unusable. + seedTimeMs?: number | null; } -// Parse a control request body: JSON { id, action, deleteFiles? }. Returns null -// for anything missing the two required string fields; the action string itself -// is validated later so an unknown action gets a precise error. +// Parse a control request body: JSON { id, action, deleteFiles?, seedTime? }. +// Returns null for anything missing the two required string fields; the action +// string itself is validated later so an unknown action gets a precise error. export function parseControl(bodyText: string): ControlRequest | null { const raw = bodyText.trim(); if (!raw.startsWith("{")) return null; @@ -125,10 +161,10 @@ export function parseControl(bodyText: string): ControlRequest | null { const id = typeof obj.id === "string" ? obj.id.trim() : ""; const action = typeof obj.action === "string" ? obj.action.trim() : ""; if (!id || !action) return null; - return { id, action, deleteFiles: obj.deleteFiles === true }; + return { id, action, deleteFiles: obj.deleteFiles === true, seedTimeMs: seedTimeField(obj.seedTime) }; } -export type ControlOutcome = "ok" | "not-found" | "unknown-action"; +export type ControlOutcome = "ok" | "not-found" | "unknown-action" | "invalid-seed-time"; // Apply a parsed control request to the queue. Pure over the runtime so it's // unit-testable with a fake queue. @@ -137,7 +173,7 @@ export async function applyControl( req: ControlRequest, ): Promise { const q = runtime.queue; - const { id, action, deleteFiles } = req; + const { id, action, deleteFiles, seedTimeMs } = req; switch (action as ControlAction) { case "pause": if (!q.has(id)) return "not-found"; @@ -162,6 +198,10 @@ export async function applyControl( const found = await q.remove(id, { deleteFiles: action === "delete" || deleteFiles }); return found ? "ok" : "not-found"; } + case "seed-time": { + if (seedTimeMs === null) return "invalid-seed-time"; + return q.setSeedTime(id, seedTimeMs) ? "ok" : "not-found"; + } default: return "unknown-action"; } @@ -175,14 +215,27 @@ function statusPayload(runtime: Runtime): Record { progress: it.progress, peers: it.peers, speed: it.speed, + ...(it.seedTimeMs !== undefined ? { seedTimeMs: it.seedTimeMs } : {}), })); - const seeds = runtime.queue.getSeeds().map((s) => ({ - id: s.id, - name: s.name, - status: s.status, - peers: s.peers, - uploaded: s.uploaded, - })); + const history = new Map(runtime.queue.getHistory().map((h) => [h.id, h])); + const seeds = runtime.queue.getSeeds().map((s) => { + const h = history.get(s.id); + return { + id: s.id, + name: s.name, + status: s.status, + peers: s.peers, + uploaded: s.uploaded, + // Only a torrent's own limit is reported; a daemon-wide --seed-time is + // the caller's to know. seedUntil is when that own limit falls due. + ...(h?.seedTimeMs !== undefined + ? { + seedTimeMs: h.seedTimeMs, + seedUntil: h.seedTimeMs > 0 ? h.completedAt + h.seedTimeMs : null, + } + : {}), + }; + }); return { downloads, seeds }; } @@ -208,17 +261,20 @@ export async function handleApi( // A .torrent is tried first because it is strictly more information: it // carries the piece hashes, so data already on disk verifies locally // instead of waiting on a swarm to serve metadata back. + const seedTimeMs = extractSeedTime(bodyText); + if (seedTimeMs === null) return { status: 400, body: { error: "invalid seedTime" } }; + const addOptions = seedTimeMs !== undefined ? { seedTimeMs } : {}; const bytes = extractTorrentBytes(bodyText); if (bytes) { const parsed = await magnetFromTorrentBytes(bytes); if (!parsed) return { status: 400, body: { error: "invalid .torrent" } }; - const outcome = await addInput(runtime, parsed.magnet); + const outcome = await addInput(runtime, parsed.magnet, addOptions); if (outcome === "invalid") return { status: 400, body: { error: "invalid .torrent" } }; return { status: 200, body: { ok: true, outcome, infoHash: parsed.infoHash } }; } const magnet = extractMagnet(bodyText); if (!magnet) return { status: 400, body: { error: "missing magnet, info hash or .torrent" } }; - const outcome = await addInput(runtime, magnet); + const outcome = await addInput(runtime, magnet, addOptions); if (outcome === "invalid") return { status: 400, body: { error: "invalid magnet or info hash" } }; return { status: 200, body: { ok: true, outcome } }; } @@ -229,6 +285,7 @@ export async function handleApi( if (outcome === "unknown-action") { return { status: 400, body: { error: `unknown action: ${req.action}` } }; } + if (outcome === "invalid-seed-time") return { status: 400, body: { error: "invalid seedTime" } }; if (outcome === "not-found") return { status: 404, body: { error: "no such torrent" } }; return { status: 200, body: { ok: true, id: req.id, action: req.action } }; } @@ -287,9 +344,9 @@ export async function runServe(options: ServeOptions = {}): Promise { const runtime = await startRuntime(options.downloadDir); - if (options.seedTimeMs && options.seedTimeMs > 0) { - startSeedReaper(runtime.queue, options.seedTimeMs, { deleteFiles: options.deleteFiles, log }); - } + // Always on: with no --seed-time it only acts on torrents that carry their + // own limit (set over the API), and does nothing at all otherwise. + startSeedReaper(runtime.queue, options.seedTimeMs ?? 0, { deleteFiles: options.deleteFiles, log }); const server = http.createServer((req, res) => { void (async () => { diff --git a/src/daemon/watch.ts b/src/daemon/watch.ts index 1389e193..4f62545e 100644 --- a/src/daemon/watch.ts +++ b/src/daemon/watch.ts @@ -100,9 +100,9 @@ export async function runWatch( const runtime = await startRuntime(downloadDir); runtime.queue.on("completed", (name: string) => log(`done, now seeding: ${name}`)); - if (options.seedTimeMs && options.seedTimeMs > 0) { - startSeedReaper(runtime.queue, options.seedTimeMs, { deleteFiles: options.deleteFiles, log }); - } + // Always on: with no --seed-time it only acts on torrents that carry their + // own limit (set over the API), and does nothing at all otherwise. + startSeedReaper(runtime.queue, options.seedTimeMs ?? 0, { deleteFiles: options.deleteFiles, log }); log(`watching ${dir}`); log(`downloads -> ${runtime.downloadDir}`); diff --git a/src/download/history.ts b/src/download/history.ts index e71e72cf..1f0cb8c5 100644 --- a/src/download/history.ts +++ b/src/download/history.ts @@ -14,6 +14,10 @@ export interface HistoryItem { magnet: string; dir: string; completedAt: number; + // Per-torrent seed limit (ms after completedAt), set through the headless + // API. Overrides the daemon-wide --seed-time; 0 means never stop seeding + // this one. Absent = inherit whatever the daemon was started with. + seedTimeMs?: number; } const write = serializeWrites(); diff --git a/src/download/queue.test.ts b/src/download/queue.test.ts index ea8a34a0..5e56c370 100644 --- a/src/download/queue.test.ts +++ b/src/download/queue.test.ts @@ -297,3 +297,45 @@ describe("DownloadQueue error resilience on boot", () => { q.suspend(); }); }); + +describe("DownloadQueue per-torrent seed time", () => { + it("setSeedTime updates a history entry and clears it again with undefined", () => { + const q = new DownloadQueue(); + q.restoreHistory([h({ id: "st1" })]); + expect(q.setSeedTime("st1", 86_400_000)).toBe(true); + expect(q.getHistory()[0]?.seedTimeMs).toBe(86_400_000); + expect(q.setSeedTime("st1", undefined)).toBe(true); + expect("seedTimeMs" in q.getHistory()[0]!).toBe(false); + }); + + it("setSeedTime reaches a download that has not finished yet", () => { + const q = new DownloadQueue(); + // Safe mode brings the item back paused without starting an engine. + q.restore( + [ + { + id: "st2", + name: "Still going", + magnet: "magnet:?xt=urn:btih:st2", + dir: "/d", + status: "downloading", + progress: 0.2, + totalBytes: 10, + downloadedBytes: 2, + speed: 0, + peers: 0, + addedAt: 1, + }, + ], + { safe: true }, + ); + expect(q.setSeedTime("st2", 0)).toBe(true); + expect(q.getItems().find((it) => it.id === "st2")?.seedTimeMs).toBe(0); + q.suspend(); + }); + + it("setSeedTime reports an id it has never seen", () => { + const q = new DownloadQueue(); + expect(q.setSeedTime("nope", 1000)).toBe(false); + }); +}); diff --git a/src/download/queue.ts b/src/download/queue.ts index d48ee395..a5c2f3da 100644 --- a/src/download/queue.ts +++ b/src/download/queue.ts @@ -59,6 +59,9 @@ export interface AddInput { magnet: string; source?: SourceId; sizeBytes?: number; + // Stop seeding this long (ms) after it finishes; 0 = never. Undefined leaves + // the daemon-wide --seed-time in charge. + seedTimeMs?: number; } export interface RestoreOptions { @@ -127,6 +130,7 @@ export class DownloadQueue extends EventEmitter { status: "downloading", error: undefined, speed: 0, + ...(input.seedTimeMs !== undefined ? { seedTimeMs: input.seedTimeMs } : {}), ...(existing.dir === dir ? {} : { progress: 0, downloadedBytes: 0, eta: undefined }), @@ -144,6 +148,7 @@ export class DownloadQueue extends EventEmitter { speed: 0, peers: 0, addedAt: Date.now(), + ...(input.seedTimeMs !== undefined ? { seedTimeMs: input.seedTimeMs } : {}), }; // Respect the concurrent-download cap: start now if a slot is free, else // hold the torrent as "queued" until one frees (see promote()). @@ -713,6 +718,30 @@ export class DownloadQueue extends EventEmitter { return this.history; } + // Set (or with undefined, clear) the per-torrent seed limit on a download + // still in flight or on a finished one. Returns false when the id is unknown + // to both, so a caller can answer not-found without a second lookup. The + // seed reaper reads the value from history, so a finished torrent's timer + // changes on its next tick; an in-flight one carries the value into history + // when it completes. + setSeedTime(id: string, seedTimeMs: number | undefined): boolean { + const it = this.items.get(id); + if (it) { + if (seedTimeMs === undefined) delete it.seedTimeMs; + else it.seedTimeMs = seedTimeMs; + void this.persist(); + } + const h = this.history.find((x) => x.id === id); + if (h) { + if (seedTimeMs === undefined) delete h.seedTimeMs; + else h.seedTimeMs = seedTimeMs; + void saveHistory(this.history).catch(() => {}); + } + if (!it && !h) return false; + this.changed(); + return true; + } + private recordHistory(it: QueueItem): void { const rec: HistoryItem = { id: it.id, @@ -722,6 +751,7 @@ export class DownloadQueue extends EventEmitter { magnet: it.magnet, dir: it.dir, completedAt: Date.now(), + ...(it.seedTimeMs !== undefined ? { seedTimeMs: it.seedTimeMs } : {}), }; this.history = [rec, ...this.history.filter((h) => h.id !== it.id)].slice(0, HISTORY_MAX); void saveHistory(this.history).catch(() => {}); diff --git a/src/download/types.ts b/src/download/types.ts index 5b4639e9..fc873297 100644 --- a/src/download/types.ts +++ b/src/download/types.ts @@ -36,4 +36,6 @@ export interface QueueItem { files?: number; error?: string; addedAt: number; + // Carried from add() into the history entry on completion (see HistoryItem). + seedTimeMs?: number; } From 353c332801edf1fa3293b548a2b7dee7fe828278 Mon Sep 17 00:00:00 2001 From: Christian Kaiser <5065635+kaiserc@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:26:15 +0100 Subject: [PATCH 2/5] fix(download): disable uTP to prevent ENOBUFS socket exhaustion crashes (#185) --- src/download/engine.test.ts | 15 +++++++++++++++ src/download/engine.ts | 16 +++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/download/engine.test.ts b/src/download/engine.test.ts index 476cd371..d954398c 100644 --- a/src/download/engine.test.ts +++ b/src/download/engine.test.ts @@ -108,4 +108,19 @@ describe("TorrentEngine macOS port-5350 fix (#22)", () => { expect(result?.total).toBe(0); engine.destroy(); }); + + it("passes utp:false so utp-native does not exhaust UDP buffers or crash on unhandled bind errors", async () => { + const { TorrentEngine } = await import("./engine"); + const engine = new TorrentEngine(); + engine.add( + "test-id", + "magnet:?xt=urn:btih:0000000000000000000000000000000000000000", + "/downloads", + {}, + ); + engine.destroy(); + expect(constructorCalls).toHaveLength(1); + expect(constructorCalls[0]).toMatchObject({ utp: false }); + }); }); + diff --git a/src/download/engine.ts b/src/download/engine.ts index 17868c5d..3c80582c 100644 --- a/src/download/engine.ts +++ b/src/download/engine.ts @@ -45,7 +45,21 @@ export class TorrentEngine { // the app the moment a download starts. NAT-PMP can never succeed // on macOS because the port is permanently taken, so disable it // and let UPnP handle NAT traversal instead. - const opts = process.platform === "darwin" ? { natPmp: false } : {}; + // + // Disable uTP across all platforms: + // WebTorrent's uTP implementation relies on `utp-native`, which allocates + // an independent UDP socket for every outgoing connection attempt. Under + // peer discovery churn, this rapidly exhausts socket buffer space and + // ephemeral ports (leading to WSAENOBUFS / ENOBUFS: "no buffer space available"). + // Furthermore, when `utp-native` fails to bind, it emits an unhandled 'error' + // on an internal EventEmitter with no listeners, crashing the process as an + // uncaught exception. Disabling uTP forces WebTorrent to use standard TCP + // connections, which are supported by 100% of BitTorrent clients and operate + // reliably without socket buffer exhaustion. + const opts = { + utp: false, + ...(process.platform === "darwin" ? { natPmp: false } : {}), + }; this.client = new WebTorrent(opts); this.client.on("error", () => {}); } From cd230f1de8ed6dc5cb56333b2454fdc8243014b4 Mon Sep 17 00:00:00 2001 From: "bairon.dev" Date: Sun, 6 Sep 2026 14:02:27 -0400 Subject: [PATCH 3/5] fix(download): restore uTP by default, add TORLINK_NO_UTP opt-out #185 disabled uTP on every platform to stop ENOBUFS socket exhaustion crashes. That is wider than the failure it fixes: every major BitTorrent client ships with TCP and uTP both enabled, and turning uTP off also stops torlink accepting inbound uTP and narrows the NAT port mapping to TCP only. The exhaustion is a webtorrent dial-path defect rather than a cost of uTP. utp-native multiplexes fine, and webtorrent already holds a bound uTP socket on the TCP port, but lib/torrent.js dials through the module-level UTP.connect, which allocates a fresh UDP socket per outgoing peer. Sockets then scale with peer count until the ephemeral port pool or buffer space runs out, and a failed bind is re-emitted on an emitter nothing listens to, so it arrives as an uncaughtException. uTP is on again by default, as it was before #185. TORLINK_NO_UTP disables it for anyone hitting the crash before that dial path is fixed upstream. --- src/download/engine.test.ts | 49 +++++++++++++++++++++++++++++-------- src/download/engine.ts | 24 +++++++++--------- 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/src/download/engine.test.ts b/src/download/engine.test.ts index d954398c..28ae824e 100644 --- a/src/download/engine.test.ts +++ b/src/download/engine.test.ts @@ -108,19 +108,48 @@ describe("TorrentEngine macOS port-5350 fix (#22)", () => { expect(result?.total).toBe(0); engine.destroy(); }); +}); - it("passes utp:false so utp-native does not exhaust UDP buffers or crash on unhandled bind errors", async () => { +describe("TorrentEngine uTP opt-out (TORLINK_NO_UTP)", () => { + it("leaves uTP on by default, the way other BitTorrent clients ship it", async () => { const { TorrentEngine } = await import("./engine"); - const engine = new TorrentEngine(); - engine.add( - "test-id", - "magnet:?xt=urn:btih:0000000000000000000000000000000000000000", - "/downloads", - {}, - ); - engine.destroy(); + const original = process.env.TORLINK_NO_UTP; + delete process.env.TORLINK_NO_UTP; + try { + const engine = new TorrentEngine(); + engine.add( + "test-id", + "magnet:?xt=urn:btih:0000000000000000000000000000000000000000", + "/downloads", + {}, + ); + engine.destroy(); + } finally { + if (original === undefined) delete process.env.TORLINK_NO_UTP; + else process.env.TORLINK_NO_UTP = original; + } + expect(constructorCalls).toHaveLength(1); + expect(constructorCalls[0]).not.toHaveProperty("utp", false); + }); + + it("passes utp:false when TORLINK_NO_UTP is set, so utp-native cannot exhaust sockets", async () => { + const { TorrentEngine } = await import("./engine"); + const original = process.env.TORLINK_NO_UTP; + process.env.TORLINK_NO_UTP = "1"; + try { + const engine = new TorrentEngine(); + engine.add( + "test-id", + "magnet:?xt=urn:btih:0000000000000000000000000000000000000000", + "/downloads", + {}, + ); + engine.destroy(); + } finally { + if (original === undefined) delete process.env.TORLINK_NO_UTP; + else process.env.TORLINK_NO_UTP = original; + } expect(constructorCalls).toHaveLength(1); expect(constructorCalls[0]).toMatchObject({ utp: false }); }); }); - diff --git a/src/download/engine.ts b/src/download/engine.ts index 3c80582c..d415aac6 100644 --- a/src/download/engine.ts +++ b/src/download/engine.ts @@ -46,18 +46,20 @@ export class TorrentEngine { // on macOS because the port is permanently taken, so disable it // and let UPnP handle NAT traversal instead. // - // Disable uTP across all platforms: - // WebTorrent's uTP implementation relies on `utp-native`, which allocates - // an independent UDP socket for every outgoing connection attempt. Under - // peer discovery churn, this rapidly exhausts socket buffer space and - // ephemeral ports (leading to WSAENOBUFS / ENOBUFS: "no buffer space available"). - // Furthermore, when `utp-native` fails to bind, it emits an unhandled 'error' - // on an internal EventEmitter with no listeners, crashing the process as an - // uncaught exception. Disabling uTP forces WebTorrent to use standard TCP - // connections, which are supported by 100% of BitTorrent clients and operate - // reliably without socket buffer exhaustion. + // TORLINK_NO_UTP turns uTP off. It stays on by default, the way every + // major client ships it, but webtorrent can exhaust the socket pool with + // it: `utp-native` multiplexes perfectly well (UTP.prototype.connect + // reuses an existing binding) and webtorrent already holds a bound uTP + // socket on the TCP port, yet lib/torrent.js dials through the + // module-level `UTP.connect`, which allocates a fresh UDP socket per + // outgoing peer. Sockets then scale with peer count until the ephemeral + // port pool or buffer space runs out (WSAENOBUFS on Windows, EMFILE + // against the fd limit elsewhere), and a failed bind is re-emitted on an + // emitter nothing listens to, so it arrives as an uncaughtException and + // kills the process. The opt-out is a workaround until that dial path is + // fixed upstream. const opts = { - utp: false, + ...(process.env.TORLINK_NO_UTP ? { utp: false } : {}), ...(process.platform === "darwin" ? { natPmp: false } : {}), }; this.client = new WebTorrent(opts); From 0eef299d91bfb2cea1ea6bd7fb0a191e467b2151 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 14 Sep 2026 21:52:36 -0700 Subject: [PATCH 4/5] feat: generate playlists for torrent media folders (#192) Co-authored-by: bairon.dev --- README.md | 2 + src/cli/args.test.ts | 16 ++++ src/cli/args.ts | 20 ++++- src/daemon/files.test.ts | 1 + src/daemon/files.ts | 1 + src/daemon/runtime.ts | 4 +- src/daemon/seed.ts | 4 +- src/daemon/serve.ts | 3 +- src/daemon/watch.ts | 3 +- src/download/engine.test.ts | 15 ++++ src/download/engine.ts | 6 ++ src/download/playlist.test.ts | 130 ++++++++++++++++++++++++++++ src/download/playlist.ts | 78 +++++++++++++++++ src/download/queue.playlist.test.ts | 70 +++++++++++++++ src/download/queue.ts | 9 +- src/index.tsx | 6 +- src/ui/App.tsx | 7 +- 17 files changed, 363 insertions(+), 12 deletions(-) create mode 100644 src/download/playlist.test.ts create mode 100644 src/download/playlist.ts create mode 100644 src/download/queue.playlist.test.ts diff --git a/README.md b/README.md index 3e6717b8..07e750af 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ Downloads run in the background while you keep searching, so you can queue up as torlink's Downloads pane: live progress on top, recently downloaded below

+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: diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 194d976e..09a70bea 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -2,6 +2,22 @@ import { describe, it, expect } from "vitest"; import { 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" }); }); diff --git a/src/cli/args.ts b/src/cli/args.ts index 790b393f..17f443c5 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -6,9 +6,10 @@ export type SearchCategory = "games" | "movies" | "tv" | "anime"; export type CliCommand = | { kind: "version" } | { kind: "help" } - | { kind: "run"; initialMagnet?: string; initialTorrent?: string } + | { kind: "run"; initialMagnet?: string; initialTorrent?: string; playlist?: boolean } | { kind: "watch"; + playlist?: boolean; dir: string; downloadDir?: string; seedTimeMs?: number; @@ -17,6 +18,7 @@ export type CliCommand = } | { kind: "serve"; + playlist?: boolean; port?: number; host?: string; token?: string; @@ -80,6 +82,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() !== ""); if (args.length === 0) return { kind: "run" }; const a = args[0]!; @@ -189,6 +201,12 @@ once open: type to search every source at once, enter to run, arrows to move, d to download, ? for keys tip: quote magnet links (they contain & characters) +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 and it downloads then seeds. Add --to to choose where files land. Handled files move to /.processed (or /.failed). diff --git a/src/daemon/files.test.ts b/src/daemon/files.test.ts index 31f9a664..3f5b2565 100644 --- a/src/daemon/files.test.ts +++ b/src/daemon/files.test.ts @@ -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"); diff --git a/src/daemon/files.ts b/src/daemon/files.ts index 5e26cf9d..092a1a24 100644 --- a/src/daemon/files.ts +++ b/src/daemon/files.ts @@ -17,6 +17,7 @@ import { LOOPBACK_HOSTS, isAuthorized, hostHeaderOk } from "./auth"; export const DEFAULT_FILES_PORT = 9160; const MIME: Record = { + ".m3u": "audio/x-mpegurl; charset=utf-8", ".mp4": "video/mp4", ".m4v": "video/mp4", ".mkv": "video/x-matroska", diff --git a/src/daemon/runtime.ts b/src/daemon/runtime.ts index 4cc3fef9..c90e91ee 100644 --- a/src/daemon/runtime.ts +++ b/src/daemon/runtime.ts @@ -31,9 +31,9 @@ export interface Runtime { // 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 { +export async function startRuntime(overrideDir?: string, options: { playlist?: boolean } = {}): Promise { const cfg = await loadConfig(); - const queue = new DownloadQueue(); + const queue = new DownloadQueue({ playlist: options.playlist }); queue.setTrackers(cfg.trackers); // Crash-boot breaker, mirroring the TUI: a marker left by the previous run // means it died mid-restore, so restore paused with the engine cold. diff --git a/src/daemon/seed.ts b/src/daemon/seed.ts index e74b58a5..b5101cbe 100644 --- a/src/daemon/seed.ts +++ b/src/daemon/seed.ts @@ -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"); diff --git a/src/daemon/serve.ts b/src/daemon/serve.ts index 6f50e76a..0ca548a6 100644 --- a/src/daemon/serve.ts +++ b/src/daemon/serve.ts @@ -31,6 +31,7 @@ export interface ApiResponse { } export interface ServeOptions { + playlist?: boolean; port?: number; host?: string; token?: string; @@ -342,7 +343,7 @@ export async function runServe(options: ServeOptions = {}): Promise { return; } - const runtime = await startRuntime(options.downloadDir); + const runtime = await startRuntime(options.downloadDir, { playlist: options.playlist }); // Always on: with no --seed-time it only acts on torrents that carry their // own limit (set over the API), and does nothing at all otherwise. diff --git a/src/daemon/watch.ts b/src/daemon/watch.ts index 4f62545e..cb888997 100644 --- a/src/daemon/watch.ts +++ b/src/daemon/watch.ts @@ -85,6 +85,7 @@ const POLL_MS = 2000; // fs.watch is unreliable across platforms (misses events, fires twice, no // recursion guarantees), so we poll — dead simple and identical on every OS. export interface WatchOptions { + playlist?: boolean; seedTimeMs?: number; deleteFiles?: boolean; } @@ -97,7 +98,7 @@ export async function runWatch( const dir = path.resolve(watchDir); await fs.mkdir(dir, { recursive: true }).catch(() => {}); - const runtime = await startRuntime(downloadDir); + const runtime = await startRuntime(downloadDir, { playlist: options.playlist }); runtime.queue.on("completed", (name: string) => log(`done, now seeding: ${name}`)); // Always on: with no --seed-time it only acts on torrents that carry their diff --git a/src/download/engine.test.ts b/src/download/engine.test.ts index 28ae824e..472b7a1a 100644 --- a/src/download/engine.test.ts +++ b/src/download/engine.test.ts @@ -110,6 +110,21 @@ describe("TorrentEngine macOS port-5350 fix (#22)", () => { }); }); +describe("TorrentEngine.filePaths", () => { + it("lists a torrent's file paths, and nothing for an unknown id", async () => { + const { TorrentEngine } = await import("./engine"); + const engine = new TorrentEngine(); + const fakeTorrent = Object.assign(new EventEmitter(), { + files: [{ path: "Course/1.mp4" }, { path: "Course/2.mp4" }], + }); + (engine as unknown as { torrents: Map }).torrents.set("course", fakeTorrent); + + expect(engine.filePaths("course")).toEqual(["Course/1.mp4", "Course/2.mp4"]); + expect(engine.filePaths("missing")).toEqual([]); + engine.destroy(); + }); +}); + describe("TorrentEngine uTP opt-out (TORLINK_NO_UTP)", () => { it("leaves uTP on by default, the way other BitTorrent clients ship it", async () => { const { TorrentEngine } = await import("./engine"); diff --git a/src/download/engine.ts b/src/download/engine.ts index d415aac6..91b832e4 100644 --- a/src/download/engine.ts +++ b/src/download/engine.ts @@ -126,6 +126,12 @@ export class TorrentEngine { return this.client?.torrentPort ?? null; } + // A torrent's file paths relative to its download dir, top-level folder + // included; empty before metadata arrives or once the torrent is gone. + filePaths(id: string): string[] { + return (this.torrents.get(id)?.files ?? []).map((file) => file.path); + } + stats(id: string): TorrentProgress | null { const t = this.torrents.get(id); if (!t) return null; diff --git a/src/download/playlist.test.ts b/src/download/playlist.test.ts new file mode 100644 index 00000000..00d0fb8b --- /dev/null +++ b/src/download/playlist.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { buildPlaylists, writePlaylists } from "./playlist"; +import { logCrash } from "../util/crashlog"; + +vi.mock("../util/crashlog", () => ({ logCrash: vi.fn() })); + +describe("buildPlaylists", () => { + it("collects descendants at every ancestor and naturally orders modules and lessons", () => { + const playlists = buildPlaylists([ + "Course/Module 10/1.mp4", + "Course/Module 2/10.mp4", + "Course/Module 2/2.mp4", + "Course/Module 2/1.MP4", + "Course/Module 1/1.mp4", + "Course/Module 2/notes.pdf", + "Course/Module 2/captions.srt", + "Course/Module 2/cover.jpg", + "Course/Module 2/example.ts", + ]); + expect([...playlists.keys()].sort()).toEqual(["Course/Module 2/playlist.m3u", "Course/playlist.m3u"]); + expect(playlists.get("Course/playlist.m3u")).toBe( + "#EXTM3U\n./Module 1/1.mp4\n./Module 2/1.MP4\n./Module 2/2.mp4\n./Module 2/10.mp4\n./Module 10/1.mp4\n", + ); + expect(playlists.get("Course/Module 2/playlist.m3u")).toBe("#EXTM3U\n./1.MP4\n./2.mp4\n./10.mp4\n"); + }); + + it("includes audio and deeply nested single lessons in their common ancestors", () => { + const playlists = buildPlaylists([ + "Course/Lessons/1/intro.MP3", + "Course/Lessons/2/lesson.flac", + "Course/notes.txt", + ]); + expect([...playlists.keys()]).toEqual(["Course/playlist.m3u", "Course/Lessons/playlist.m3u"]); + }); + + it.each([ + [], + ["movie.mp4"], + ["Course/Module 1/lesson.mp4", "Course/readme.txt", "Course/cover.png"], + ["One/one.mp4", "Two/two.mp4"], + ["Course/one.mp4", "Course/one.mp4"], + ["Course/readme.txt", "Course/notes.pdf"], + ])("skips single-media and non-media folders: %j", (...files) => { + expect(buildPlaylists(files as string[]).size).toBe(0); + }); + + it("uses native torrent paths and writes entries that open as-is from the playlist's folder", () => { + // mpv joins an entry onto the playlist's folder verbatim, so nothing may be + // percent-encoded. The `./` keeps "#4 bonus" from reading as a comment. + const names = ["1 #intro 100%.mp4", "2 café & résumé?.mp3", "3: recap.mp4", "#4 bonus.mp4"]; + const playlists = buildPlaylists(names.map((name) => path.join("Course", name))); + const entries = playlists.get("Course/playlist.m3u")!.trim().split("\n").slice(1); + expect(entries).toHaveLength(names.length); + expect(entries.every((entry) => entry.startsWith("./"))).toBe(true); + expect(entries.map((entry) => path.resolve("Course", entry)).sort()) + .toEqual(names.map((name) => path.resolve("Course", name)).sort()); + }); + + it("reserves playlists supplied by the torrent, including differently cased names", () => { + expect(buildPlaylists(["Course/1.mp4", "Course/2.mp4", "Course/Playlist.M3U"]).size).toBe(0); + }); + + it("ignores traversal, absolute paths, and playlist injection", () => { + const bad = ["../escape", "/absolute", "C:/drive", "\\\\server\\share", "Course/../escape", "Course\n#EXTINF:0,bad"]; + const files = bad.flatMap((dir) => [`${dir}/1.mp4`, `${dir}/2.mp4`]); + expect(buildPlaylists(files).size).toBe(0); + }); +}); + +describe("writePlaylists", () => { + let dir: string; + beforeEach(async () => { + vi.clearAllMocks(); + dir = await fs.mkdtemp(path.join(os.tmpdir(), "torlink-playlists-")); + }); + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it("writes only inside the torrent's existing folders and preserves existing playlists", async () => { + const module = path.join(dir, "Course", "Module"); + await fs.mkdir(module, { recursive: true }); + const existing = path.join(module, "playlist.m3u"); + await fs.writeFile(existing, "my custom playlist\n"); + const files = ["Course/Module/1.mp4", "Course/Module/2.mp4"]; + await writePlaylists(dir, files); + expect(await fs.readFile(path.join(dir, "Course", "playlist.m3u"), "utf8")) + .toBe("#EXTM3U\n./Module/1.mp4\n./Module/2.mp4\n"); + expect(await fs.readFile(existing, "utf8")).toBe("my custom playlist\n"); + expect(await fs.readdir(dir)).toEqual(["Course"]); + await writePlaylists(dir, files); + expect(logCrash).not.toHaveBeenCalled(); + }); + + it("does not recreate deleted folders and still writes other eligible playlists", async () => { + await fs.mkdir(path.join(dir, "Course", "Kept"), { recursive: true }); + await expect(writePlaylists(dir, [ + "Course/Deleted/1.mp4", "Course/Deleted/2.mp4", + "Course/Kept/1.mp4", "Course/Kept/2.mp4", + ])).resolves.toBeUndefined(); + expect(await fs.readdir(path.join(dir, "Course"))).toEqual(["Kept", "playlist.m3u"]); + expect(await fs.readFile(path.join(dir, "Course", "Kept", "playlist.m3u"), "utf8")) + .toBe("#EXTM3U\n./1.mp4\n./2.mp4\n"); + expect(logCrash).toHaveBeenCalledWith("playlist", expect.any(Error)); + }); + + it("refuses to write through a directory symlink outside the downloads root", async () => { + const downloads = path.join(dir, "downloads"); + const outside = path.join(dir, "outside"); + await fs.mkdir(downloads); + await fs.mkdir(outside); + await fs.symlink(outside, path.join(downloads, "Course"), "junction"); + await writePlaylists(downloads, ["Course/1.mp4", "Course/2.mp4"]); + expect(await fs.readdir(outside)).toEqual([]); + }); + + it("leaves an existing playlist symlink alone", async () => { + const course = path.join(dir, "Course"); + const outside = path.join(dir, "outside"); + await fs.mkdir(course); + await fs.mkdir(outside); + await fs.symlink(outside, path.join(course, "playlist.m3u"), "junction"); + await writePlaylists(dir, ["Course/1.mp4", "Course/2.mp4"]); + expect((await fs.lstat(path.join(course, "playlist.m3u"))).isSymbolicLink()).toBe(true); + expect(await fs.readdir(outside)).toEqual([]); + }); +}); diff --git a/src/download/playlist.ts b/src/download/playlist.ts new file mode 100644 index 00000000..32c93a18 --- /dev/null +++ b/src/download/playlist.ts @@ -0,0 +1,78 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { logCrash } from "../util/crashlog"; + +const MEDIA_EXTENSIONS = new Set([ + ".aac", ".aiff", ".avi", ".flac", ".flv", ".m2ts", ".m4a", ".m4v", + ".mka", ".mkv", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".oga", + ".ogg", ".ogv", ".opus", ".wav", ".webm", ".wma", ".wmv", +]); +const natural = new Intl.Collator("en", { numeric: true, sensitivity: "base" }); + +// WebTorrent's file paths include the torrent's top-level directory. Group +// media under each ancestor, stopping before the shared downloads directory. +// A course gets a whole-course playlist and one per module with 2+ lessons. +export function buildPlaylists(filePaths: string[]): Map { + const groups = new Map(); + const files = new Set(filePaths.map((file) => file.split(path.sep).join("/"))); + for (const file of files) { + const parts = file.split("/"); + // Reject unsafe/ambiguous paths on every platform, including Windows + // drive/UNC paths and newlines that could inject extra playlist entries. + if ( + /[\\\x00-\x1f]/.test(file) || /^[a-z]:/i.test(file) || + parts.some((p) => !p || p === "." || p === "..") + ) continue; + if (!MEDIA_EXTENSIONS.has(path.posix.extname(file).toLowerCase())) continue; + for (let i = 1; i < parts.length; i++) { + const dir = parts.slice(0, i).join("/"); + const entries = groups.get(dir) ?? []; + entries.push(parts.slice(i).join("/")); + groups.set(dir, entries); + } + } + + const playlists = new Map(); + const occupied = new Set([...files].map((file) => file.toLowerCase())); + for (const [dir, entries] of groups) { + const target = `${dir}/playlist.m3u`; + // Never replace a file supplied by the torrent, even if it is absent on + // disk. Also leave single-file folders alone: opening the file is enough. + if (entries.length < 2 || occupied.has(target.toLowerCase())) continue; + entries.sort((a, b) => natural.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0)); + // Plain relative paths, not URIs: mpv joins an entry onto the playlist's + // folder verbatim, so `Module%201` would never be found, while VLC fixes up + // raw spaces and Unicode itself. The `./` keeps a name that starts with `#` + // from reading as a comment. + const lines = entries.map((entry) => `./${entry}`); + playlists.set(target, `#EXTM3U\n${lines.join("\n")}\n`); + } + return playlists; +} + +// Only create new files in existing torrent directories. Exclusive creation +// preserves user playlists (and symlinks); no mkdir means a removed torrent +// cannot be recreated by this background completion task. +export async function writePlaylists(downloadDir: string, filePaths: string[]): Promise { + try { + const playlists = buildPlaylists(filePaths); + if (playlists.size === 0) return; + const root = await fs.realpath(downloadDir); + for (const [relative, content] of playlists) { + try { + const target = path.join(root, relative); + const parent = await fs.realpath(path.dirname(target)); + const inside = path.relative(root, parent); + if ( + !inside || inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside) + ) continue; + await fs.writeFile(path.join(parent, "playlist.m3u"), content, { encoding: "utf8", flag: "wx" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") logCrash("playlist", error); + } + } + } catch (error) { + // An optional playlist must never turn a verified download into a failure. + logCrash("playlist", error); + } +} diff --git a/src/download/queue.playlist.test.ts b/src/download/queue.playlist.test.ts new file mode 100644 index 00000000..22f9d822 --- /dev/null +++ b/src/download/queue.playlist.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DownloadQueue } from "./queue"; +import { writePlaylists } from "./playlist"; +import type { AddHandlers } from "./engine"; + +// The engine is stubbed to hand back the handlers the queue gives it, so each +// test fires `done` itself. The same event means a download finishing or a +// restored seed passing verification, and only the first may write playlists. +const handlers = new Map(); + +vi.mock("./engine", () => ({ + TorrentEngine: class { + add(id: string, _source: string, _dir: string, h: AddHandlers): void { + handlers.set(id, h); + } + filePaths(): string[] { + return ["Course/1.mp4", "Course/2.mp4"]; + } + remove(): void {} + stats(): undefined { + return undefined; + } + destroy(): void {} + }, +})); +vi.mock("./playlist", () => ({ writePlaylists: vi.fn().mockResolvedValue(undefined) })); + +const MAGNET = "magnet:?xt=urn:btih:0000000000000000000000000000000000000000"; + +beforeEach(() => { + vi.stubEnv("TORLINK_NO_PLAYLIST", ""); +}); +afterEach(() => { + handlers.clear(); + vi.clearAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("DownloadQueue playlists", () => { + it("writes playlists into the download dir when a download finishes", () => { + const q = new DownloadQueue(); + q.add({ id: "t1", name: "Course", magnet: MAGNET }, "/downloads"); + handlers.get("t1")!.onDone!(); + expect(writePlaylists).toHaveBeenCalledWith("/downloads", ["Course/1.mp4", "Course/2.mp4"]); + q.suspend(); + }); + + // Restored seeds verify on every launch, so writing here would put back a + // playlist the user deleted. + it("leaves a restored seed alone when it passes verification", () => { + const q = new DownloadQueue(); + q.restoreHistory([ + { id: "s1", name: "Course", magnet: MAGNET, dir: "/downloads", sizeBytes: 100, completedAt: 1 }, + ]); + q.restoreSeeds([{ id: "s1", status: "seeding" }]); + handlers.get("s1")!.onDone!(); + expect(writePlaylists).not.toHaveBeenCalled(); + q.suspend(); + }); + + it.each(["option", "environment"])("honors the %s opt-out while completing normally", (optOut) => { + if (optOut === "environment") vi.stubEnv("TORLINK_NO_PLAYLIST", "1"); + const q = new DownloadQueue(optOut === "option" ? { playlist: false } : {}); + q.add({ id: "t2", name: "Course", magnet: MAGNET }, "/downloads"); + handlers.get("t2")!.onDone!(); + expect(writePlaylists).not.toHaveBeenCalled(); + expect(q.getSeed("t2")?.status).toBe("seeding"); + q.suspend(); + }); +}); diff --git a/src/download/queue.ts b/src/download/queue.ts index a5c2f3da..7d3a0c47 100644 --- a/src/download/queue.ts +++ b/src/download/queue.ts @@ -14,6 +14,7 @@ import { } from "./persist"; import { saveHistory, saveHistorySync, type HistoryItem } from "./history"; import { deleteSeedData } from "./delete-data"; +import { writePlaylists } from "./playlist"; import { disarmBootMarker } from "./bootguard"; import { trackersOf } from "../sources/magnet"; import type { QueueItem, SeedItem } from "./types"; @@ -83,10 +84,13 @@ export class DownloadQueue extends EventEmitter { // Max torrents allowed to download at once; overflow waits as "queued". private readonly maxDownloads: number; + // Write playlist.m3u into a finished download's media folders. + private readonly playlist: boolean; - constructor(opts?: { maxDownloads?: number }) { + constructor(opts?: { maxDownloads?: number; playlist?: boolean }) { super(); this.maxDownloads = opts?.maxDownloads ?? readMaxDownloads(); + this.playlist = opts?.playlist ?? !process.env.TORLINK_NO_PLAYLIST; } // Extra announce URLs appended to every torrent added from now on. @@ -287,6 +291,9 @@ export class DownloadQueue extends EventEmitter { // Opt-out seeding: a finished download is already a complete, verified // torrent, so keep it alive and seeding instead of tearing it down. this.beginSeed(it); + // Only here, never when a restored seed passes verification: that fires + // on every launch, and would put back a playlist the user deleted. + if (this.playlist) void writePlaylists(it.dir, this.engine.filePaths(it.id)); this.emit("completed", it.name); this.changed(); void this.persist(); diff --git a/src/index.tsx b/src/index.tsx index 60c807bb..3048dcdd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -56,9 +56,9 @@ if (cmd.kind === "update") { void import("./update/run").then(({ runUpdate }) => runUpdate({ force: cmd.force }).catch(failHeadless)); } else if (cmd.kind === "watch") { if (cmd.daemon) daemonize("watch"); // parent exits here; the detached child continues - const { dir, downloadDir, seedTimeMs, deleteFiles } = cmd; + const { dir, downloadDir, seedTimeMs, deleteFiles, playlist } = cmd; void import("./daemon/watch").then(({ runWatch }) => - runWatch(dir, downloadDir, { seedTimeMs, deleteFiles }).catch(failHeadless), + runWatch(dir, downloadDir, { seedTimeMs, deleteFiles, playlist }).catch(failHeadless), ); } else if (cmd.kind === "seed") { if (cmd.daemon) daemonize("seed"); @@ -74,6 +74,7 @@ if (cmd.kind === "update") { host: cmd.host, token: cmd.token ?? process.env.TORLINK_API_TOKEN, downloadDir: cmd.downloadDir, + playlist: cmd.playlist, seedTimeMs: cmd.seedTimeMs, deleteFiles: cmd.deleteFiles, }; @@ -139,6 +140,7 @@ const app = render( forceExit(0)} />, { exitOnCtrlC: false }, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 441dd469..53ec7f85 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -54,8 +54,9 @@ import type { SourceId } from "../sources/types"; export function App({ initialMagnet, initialTorrent, + playlist, onQuit, -}: { initialMagnet?: string; initialTorrent?: string; onQuit?: () => void } = {}) { +}: { initialMagnet?: string; initialTorrent?: string; playlist?: boolean; onQuit?: () => void } = {}) { useMouseWheel(); const { exit } = useApp(); const { isRawModeSupported } = useStdin(); @@ -120,7 +121,7 @@ export function App({ let alive = true; void (async () => { const cfg = await loadConfig(); - const q = new DownloadQueue(); + const q = new DownloadQueue({ playlist }); q.setTrackers(cfg.trackers); // Crash-boot breaker: a marker left behind by the previous boot means it // died mid-restore, so this one restores everything paused with the @@ -169,7 +170,7 @@ export function App({ return () => { alive = false; }; - }, [initialMagnet, initialTorrent]); + }, [initialMagnet, initialTorrent, playlist]); // Best-effort, once per launch, off the hot path: if a newer release exists, // surface a quiet banner. Any failure (offline, opt-out) just leaves it hidden. From b4fea5e7ccfcea6f6b2e16262de742829b2dca4b Mon Sep 17 00:00:00 2001 From: Ash Date: Thu, 17 Sep 2026 17:02:11 +0100 Subject: [PATCH 5/5] fix: give test fake queues a getHistory() now the seed reaper always calls it runServe now starts the seed reaper unconditionally (it's a no-op without a --seed-time or a per-torrent override), so any fake Runtime.queue used in a test that exercises runServe or statusPayload needs getHistory() too. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_0113GE7YUVVDMZFBrrsUL4PK --- src/daemon/serve.test.ts | 1 + src/daemon/testHarness.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/daemon/serve.test.ts b/src/daemon/serve.test.ts index d50adcf6..890c3938 100644 --- a/src/daemon/serve.test.ts +++ b/src/daemon/serve.test.ts @@ -127,6 +127,7 @@ describe("handleApi", () => { getSeeds: () => [ { id: "s1", name: "S", status: "seeding", peers: 2, uploaded: 2048, uploadSpeed: 128, x: 1 }, ], + getHistory: () => [], } as unknown as Runtime["queue"]; const res = await handleApi(runtime, null, "GET", "/status", undefined, ""); expect(res.status).toBe(200); diff --git a/src/daemon/testHarness.ts b/src/daemon/testHarness.ts index b1e9221d..4fddbafc 100644 --- a/src/daemon/testHarness.ts +++ b/src/daemon/testHarness.ts @@ -34,6 +34,7 @@ export function fakeRuntime(downloadDir: string): Fake { off: vi.fn(), getItems: () => [], getSeeds: () => [], + getHistory: () => [], } as unknown as Runtime["queue"], downloadDir, sessions: { stopAll } as unknown as Runtime["sessions"],