87 lines · 3.5 KB · TypeScript
RawDownload
// Headless runtime: drive the download queue without the Ink TUI.//// The TUI is one front-end over DownloadQueue; a seedbox needs another that has// no terminal at all. This mirrors App.tsx's boot sequence (load config, restore// queue/history/seeds) so a headless run resumes exactly what an interactive one// would, then exposes a single addInput() the watch folder and HTTP API share. import { promises as fs } from "node:fs";import { loadConfig } from "../config/config";import { DownloadQueue } from "../download/queue";import { loadQueue, loadSeeds } from "../download/persist";import { loadHistory } from "../download/history";import { reconcileQueue } from "../download/reconcile";import {  BOOT_SETTLE_MS,  armBootMarker,  disarmBootMarker,  wasBootInterrupted,} from "../download/bootguard";import { parseInput } from "../sources/magnet";import { magnetFromTorrentFile } from "../sources/torrentFile"; export interface Runtime {  queue: DownloadQueue;  downloadDir: string;  // True when the previous run died mid-restore and this boot came up in safe  // mode: everything paused, no engines started (see download/bootguard.ts).  recovered?: boolean;} // 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> {  const cfg = await loadConfig();  const queue = new DownloadQueue();  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.  const safe = wasBootInterrupted();  armBootMarker();  queue.restore(reconcileQueue(await loadQueue()), { safe });  queue.restoreHistory(await loadHistory());  queue.restoreSeeds(await loadSeeds(), { safe });  setTimeout(disarmBootMarker, BOOT_SETTLE_MS).unref();  if (safe) {    console.error("[torlnk] recovered from a crashed start: restored downloads are paused");  }  const downloadDir = overrideDir && overrideDir.trim() ? overrideDir.trim() : cfg.downloadDir;  return { queue, downloadDir, recovered: safe };} export type AddOutcome = "added" | "duplicate" | "invalid"; // Turn a magnet URI, bare info hash, or a path to a .torrent file into a queued// download. Deduplicates by info hash (the queue's own id), so re-submitting the// same torrent is a no-op rather than a restart. Never throws — bad input is// reported as "invalid" so callers (a watcher, an HTTP handler) can fail soft.export interface AddInputOptions {  // Treat an input ending in .torrent as a local file path and read it. Only  // 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;} export async function addInput(  runtime: Runtime,  input: string,  options: AddInputOptions = {},): Promise<AddOutcome> {  const trimmed = input.trim();  let parsed;  if (/\.torrent$/i.test(trimmed)) {    if (!options.allowTorrentPath) return "invalid";    parsed = await magnetFromTorrentFile(trimmed);  } else {    parsed = parseInput(trimmed);  }  if (!parsed) return "invalid";  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 },    runtime.downloadDir,  );  return "added";}