// settings.seeder — the home seeder of last resort (release 21 D4b). // // Pure: the type, its defaults, the sanitizer settingsSchema.ts runs it // through, and the per-field docs SETTINGS.md renders. The seeder itself is // controller/seeder.ts (`archilyzer seed`); the decision of when to seed is // lib/lastResortSeeder.ts. // // NOT CONFIGURED is `sites: []`, the default: `archilyzer seed` refuses to // start and `archilyzer doctor` says "seeder not configured". import type { FieldDocs } from "./fieldDocs"; export type SeederSettings = { sites: string[]; trackers: string[]; maxUploadKiBps: number; maxConnections: number; pollSeconds: number; standbyAfterSeconds: number; bindInterface: string; }; export const DEFAULT_SEEDER_SETTINGS: SeederSettings = { sites: [], trackers: [], maxUploadKiBps: 0, maxConnections: 50, pollSeconds: 60, standbyAfterSeconds: 600, bindInterface: "", }; export const SEEDER_SETTINGS_FIELD_DOCS: FieldDocs = { sites: "The site ids whose playable torrents the seeder seeds: every channel of each site " + "that has a playable manifest (`archilyzer media playable`). Empty = the seeder is " + "not configured, and `archilyzer seed` refuses to start.", trackers: "Announce URLs the seeder announces to and scrapes (wss:// or ws:// for browsers, " + "http(s):// or udp:// for desktop clients). Empty = each torrent's own announce list.", maxUploadKiBps: "Upload cap across every torrent, KiB/s. 0 = unlimited.", maxConnections: "Most peer connections per torrent. Clamped to [1, 500]; default 50.", pollSeconds: "How often the seeder scrapes the trackers and decides, per torrent, whether to " + "seed or stand by. A viewer whose last other seeder leaves mid-stream waits up to " + "this long. Clamped to [10, 3600]; default 60.", standbyAfterSeconds: "Other seeders seen on every poll for this long → the seeder stands by on that " + "torrent (stops announcing, closes its peers, keeps the data). The same window, " + "with no other seeder seen, brings it back when nobody is waiting; a waiting " + "leecher brings it back at once. Clamped to [60, 86400]; default 600.", bindInterface: "The network interface the seeder must have — the VPN tunnel's (e.g. `wg0`, " + "`tun0`). While it is missing the seeder does not start, and stops every torrent " + "if it disappears. Empty = not checked (the docker profile `seeder` confines the " + "process to the tunnel either way; see docker-compose.seeder.yml).", }; function clampInt(value: unknown, fallback: number, min: number, max: number): number { const n = typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : fallback; return Math.min(max, Math.max(min, n)); } const SITE_ID_RE = /^[a-z0-9][a-z0-9-]*$/; const TRACKER_RE = /^(wss?|https?|udp):\/\/\S+$/i; const IFACE_RE = /^[A-Za-z0-9_.:-]{1,32}$/; function stringList(value: unknown, keep: (s: string) => boolean): string[] { if (!Array.isArray(value)) return []; const out: string[] = []; for (const v of value) { if (typeof v !== "string") continue; const s = v.trim(); if (s && keep(s) && !out.includes(s)) out.push(s); } return out; } export function sanitizeSeeder(value: unknown): SeederSettings { const d = DEFAULT_SEEDER_SETTINGS; const r = (value && typeof value === "object" && !Array.isArray(value) ? value : {}) as Record; const iface = typeof r.bindInterface === "string" ? r.bindInterface.trim() : ""; return { sites: stringList(r.sites, (s) => SITE_ID_RE.test(s)), trackers: stringList(r.trackers, (s) => TRACKER_RE.test(s)), maxUploadKiBps: clampInt(r.maxUploadKiBps, d.maxUploadKiBps, 0, 10_000_000), maxConnections: clampInt(r.maxConnections, d.maxConnections, 1, 500), pollSeconds: clampInt(r.pollSeconds, d.pollSeconds, 10, 3600), standbyAfterSeconds: clampInt(r.standbyAfterSeconds, d.standbyAfterSeconds, 60, 86_400), bindInterface: IFACE_RE.test(iface) ? iface : "", }; } export function seederConfigured(s: SeederSettings): boolean { return s.sites.length > 0; }