import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import WebTorrent from "webtorrent"; import type { Paths } from "../lib/paths"; import { DEFAULT_SEEDER_SETTINGS, type SeederSettings } from "../lib/seederSettings"; import { makePlayableTorrent } from "./preparePlayable"; import { interfacePresent, mergeScrapes, runSeeder, startTracker, type SeedTarget } from "./seeder"; // Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test controller/seeder.test.ts // // SHORT LOCALHOST INTEGRATION: a tracker on 127.0.0.1, the seeder, and a // leecher in this process; a few hundred KB of synthetic bytes. Nothing // listens on anything but loopback, no STUN server is asked for an address, // and everything is torn down at the end. const ID = "aaaaaaaaaaa"; async function until(pred: () => boolean, ms: number, what: string): Promise { const t0 = Date.now(); while (!pred()) { if (Date.now() - t0 > ms) throw new Error(`timed out waiting for ${what}`); await new Promise((r) => setTimeout(r, 100)); } } const newClient = () => new WebTorrent({ dht: false, lsd: false, natUpnp: false, natPmp: false, utp: false, webSeeds: false, tracker: { rtcConfig: { iceServers: [] } }, }); const destroy = (c: WebTorrent) => c.destroyed ? Promise.resolve() : new Promise((resolve) => c.destroy(() => resolve())); async function fixture(trackerUrl: (port: number) => string) { const dir = await mkdtemp(path.join(tmpdir(), "ttb-seeder-")); const seedDir = path.join(dir, "playable", "demo", ID); await mkdir(seedDir, { recursive: true }); const bytes = Buffer.from(Array.from({ length: 600_000 }, (_, i) => (i * 13 + 7) & 0xff)); const file = path.join(seedDir, `${ID}.mp4`); await writeFile(file, bytes); let allowed = new Set(); const tracker = await startTracker({ host: "127.0.0.1", port: 0, allowed: () => allowed }); const made = await makePlayableTorrent({ file, name: `${ID}.mp4`, bytes: bytes.length, trackers: [trackerUrl(tracker.port)] }); allowed = new Set([made.infoHash]); const torrentPath = path.join(seedDir, `${ID}.torrent`); await writeFile(torrentPath, made.torrent); const target: SeedTarget = { slug: "demo", id: ID, infoHash: made.infoHash, torrentPath, file, dir: seedDir, bytes: bytes.length }; return { dir, bytes, tracker, made, target }; } function settings(): SeederSettings { return { ...DEFAULT_SEEDER_SETTINGS, sites: ["demo-site"], maxConnections: 10 }; } function leechTo(peer: WebTorrent, torrent: Buffer, out: string, ms: number, log: string[]) { const t = peer.add(torrent, { path: out }); return { t, done: new Promise((resolve, reject) => { t.once("done", () => resolve()); t.once("error", reject); setTimeout(() => reject(new Error(`the leecher did not finish; seeder log: ${log.join(" | ")}`)), ms).unref(); }), }; } test("unit: scrape answers merge by max, and an absent interface is absent", () => { const a = new Map([["h", { complete: 2, incomplete: 0 }]]); const b = new Map([["h", { complete: 1, incomplete: 3 }]]); assert.deepEqual(mergeScrapes([a, null, b], "h"), { complete: 2, incomplete: 3 }); assert.equal(mergeScrapes([null], "h"), null); assert.equal(interfacePresent("wg0", { lo: [{ address: "127.0.0.1", internal: true } as never] }), false); assert.equal(interfacePresent("wg0", { wg0: [{ address: "10.2.0.2", internal: false } as never] }), true); }); test("unconfigured, it refuses to start", async () => { await assert.rejects( runSeeder({ paths: {} as Paths, settings: DEFAULT_SEEDER_SETTINGS, onLog: () => {} }), /not configured/, ); }); test("the tracker tracks only the torrents it is told to", { timeout: 30_000 }, async () => { const tracker = await startTracker({ host: "127.0.0.1", port: 0, allowed: () => new Set(["a".repeat(40)]) }); try { // An announce for a torrent it was not told about: the 20-byte infohash // percent-encoded byte by byte, as a BitTorrent client sends it. const raw = Buffer.from("b".repeat(40), "hex"); const infoHash = [...raw].map((b) => `%${b.toString(16).padStart(2, "0")}`).join(""); const q = `info_hash=${infoHash}&peer_id=${"-TT0000-".padEnd(20, "0")}&port=6881&uploaded=0&downloaded=0&left=0&compact=1`; const body = await (await fetch(`http://127.0.0.1:${tracker.port}/announce?${q}`)).text(); assert.match(body, /failure reason.*does not track/); assert.equal(tracker.swarm("b".repeat(40)), null); } finally { await tracker.close(); } }); test("TCP: it seeds, a leecher completes, the seeder stands by while the leecher seeds and resumes when it leaves", { timeout: 60_000 }, async () => { const f = await fixture((port) => `http://127.0.0.1:${port}/announce`); const ac = new AbortController(); const transitions: string[] = []; const log: string[] = []; const seederClient = newClient(); const peer = newClient(); const running = runSeeder({ paths: {} as Paths, settings: settings(), onLog: (l) => log.push(l), signal: ac.signal, deps: { targets: async () => [f.target], pollMs: 300, policy: { standbyAfterSeconds: 1, resumeAfterSeconds: 1 }, onTransition: (t) => transitions.push(t.to), client: seederClient, }, }); try { const out = path.join(f.dir, "leech"); await mkdir(out); await leechTo(peer, f.made.torrent, out, 30_000, log).done; assert.deepEqual(await readFile(path.join(out, `${ID}.mp4`)), f.bytes); // The leecher now seeds too: the home seeder steps back. await until(() => transitions.includes("standby"), 20_000, `standby; log: ${log.join(" | ")}`); assert.match(log.join("\n"), /seeding → standby — 1 other seeder\(s\) on every poll/); // The leecher leaves: nobody else seeds, so it comes back. await destroy(peer); await until(() => transitions.at(-1) === "seeding", 20_000, `resume; log: ${log.join(" | ")}`); assert.match(log.join("\n"), /standby → seeding — no other seeder/); } finally { ac.abort(); await running.catch(() => {}); await destroy(peer); await destroy(seederClient); await f.tracker.close(); await rm(f.dir, { recursive: true, force: true }); } }); test("WebRTC: a peer reaching it through the WebSocket tracker completes (node-datachannel)", { timeout: 60_000 }, async (t) => { if (!WebTorrent.WEBRTC_SUPPORT) { t.skip("no WebRTC in this node"); return; } const f = await fixture((port) => `ws://127.0.0.1:${port}`); const ac = new AbortController(); const log: string[] = []; const seederClient = newClient(); const peer = newClient(); const running = runSeeder({ paths: {} as Paths, settings: settings(), onLog: (l) => log.push(l), signal: ac.signal, deps: { targets: async () => [f.target], pollMs: 1000, policy: { standbyAfterSeconds: 3600, resumeAfterSeconds: 3600 }, client: seederClient, }, }); try { // The leecher announces AFTER the seeder's announce is in. Two peers whose // WebSocket offers cross in flight each answer the other's and each keep // the connection the other dropped — an artefact of starting both in the // same instant, which a browser arriving at a running seeder does not do. await until(() => (f.tracker.swarm(f.made.infoHash)?.complete ?? 0) >= 1, 20_000, "the seeder's announce"); const out = path.join(f.dir, "leech"); await mkdir(out); const { t: tor, done } = leechTo(peer, f.made.torrent, out, 40_000, log); await done; assert.deepEqual(await readFile(path.join(out, `${ID}.mp4`)), f.bytes); assert.ok(tor.wires.some((w) => w.type === "webrtc"), `wires: ${tor.wires.map((w) => w.type).join(",")}`); } finally { ac.abort(); await running.catch(() => {}); await destroy(peer); await destroy(seederClient); await f.tracker.close(); await rm(f.dir, { recursive: true, force: true }); } });