// `archilyzer seed` — the home seeder of last resort (release 21 D4b), and
// `archilyzer tracker`, the pilot's self-hosted tracker.
//
// THE SEEDER. WebTorrent 3 in node: TCP for desktop clients, WebRTC (through
// node-datachannel, which WebTorrent's peer library loads) for browsers. It
// seeds the playable torrents (controller/preparePlayable.ts) of every channel
// of the sites named in `settings.seeder.sites` — and, per torrent, only while
// no other seeder has it: lib/lastResortSeeder.ts decides, this module
// observes (each tracker's scrape, its own wires) and applies. STANDBY is the
// torrent removed from the client with its data kept (`stopped` is announced,
// every peer closed); SEEDING is it added back, trusting the copy on disk
// (`skipVerify`: the manifest's size was checked, and every peer verifies each
// piece it takes).
//
// No DHT, no local discovery, no UPnP/NAT-PMP, no web seeds: the seeder is
// found through the trackers it is given and nowhere else, and it opens no
// port on a router. `bindInterface`, when set, must exist (the VPN tunnel):
// the seeder waits for it to start, and drops every torrent the moment it is
// gone — the docker profile's namespace is the real kill switch
// (docker-compose.seeder.yml); this is the same rule in the app.
//
// THE TRACKER. bittorrent-tracker's server, HTTP + WebSocket (no UDP), bound
// to the address given (127.0.0.1 by default), tracking ONLY the infohashes
// in the playable manifests of the seeder's sites — an open tracker on a home
// connection would be anyone's.
//
// SERVER-ONLY.
///
import os from "node:os";
import path from "node:path";
import { readFile, stat } from "node:fs/promises";
import WebTorrent, { type Torrent } from "webtorrent";
import { Client as TrackerClient, Server as TrackerServer, type ScrapeData } from "bittorrent-tracker";
import parseTorrent from "parse-torrent";
import type { Paths } from "../lib/paths";
import { getSite, siteChannelSlugs } from "../lib/site";
import { readChannelConfig } from "./channels";
import { defaultPlayableRoot, loadPlayableManifest } from "./preparePlayable";
import {
initialSeederMemory,
stepSeeder,
type SeederMemory,
type SeederPolicy,
type SwarmObservation,
} from "../lib/lastResortSeeder";
import { seederConfigured, type SeederSettings } from "../lib/seederSettings";
export type SeedTarget = {
slug: string;
id: string;
infoHash: string;
torrentPath: string;
// The torrent's one file (`.`), and the directory holding it.
file: string;
dir: string;
bytes: number;
};
// Every playable torrent of the sites' channels, deduplicated by infohash.
// A site that does not load, or a channel with no manifest, contributes none.
export async function listSeedTargets(
paths: Paths,
settings: Pick,
onLog: (line: string) => void = () => {},
): Promise {
const out = new Map();
const slugs = new Set();
for (const siteId of settings.sites) {
try {
for (const slug of siteChannelSlugs(getSite(siteId, paths))) slugs.add(slug);
} catch (err) {
onLog(`site ${siteId}: ${(err as Error).message} — skipped`);
}
}
for (const slug of [...slugs].sort()) {
const config = await readChannelConfig(paths, slug).catch(() => null);
const root = await defaultPlayableRoot(paths, config ?? null);
const manifest = await loadPlayableManifest(root, slug);
for (const [id, e] of Object.entries(manifest.entries)) {
if (out.has(e.infoHash)) continue;
out.set(e.infoHash, {
slug,
id,
infoHash: e.infoHash,
torrentPath: path.join(root, slug, e.torrent),
file: path.join(root, slug, e.file),
dir: path.dirname(path.join(root, slug, e.file)),
bytes: e.bytes,
});
}
}
return [...out.values()];
}
export function interfacePresent(
name: string,
interfaces: NodeJS.Dict = os.networkInterfaces(),
): boolean {
return (interfaces[name] ?? []).some((a) => !a.internal);
}
export type ScrapeFn = (
announce: string,
infoHashes: string[],
) => Promise