Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 2484c402c66b17b2bae34b5ea57d05af8b6f885e
parent 57ae8c2a8abe38f01747471d9ff8c5892a526f5b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  9 Oct 2026 14:01:50 -0400

common: the home seeder of last resort — archilyzer seed (WebTorrent 3, TCP + WebRTC through node-datachannel) seeds a torrent only while no other seeder has it; archilyzer tracker; settings.seeder; the docker profile seeder behind a WireGuard namespace; doctor compares egress

D4b of release 21. lib/lastResortSeeder.ts is the pure state machine
(standby after other seeders on every poll for standbyAfterSeconds; back at
once for a waiting leecher with no other source, or after the same window
alone; silent trackers in standby resume; a transition restarts both
clocks), 13 unit tests against a scripted scrape. controller/seeder.ts
observes (each tracker's scrape, merged by max; its wires counted by peer)
and applies (standby = removed from the client, data kept; seeding = added
back with skipVerify after a size check); no DHT, LSD, UPnP or web seeds;
bindInterface held. The tracker is bittorrent-tracker HTTP + WS, no UDP,
tracking only the manifests' infohashes. Localhost integration tests: TCP
seed → standby → resume, and a WebRTC leecher completing through the WS
tracker. docker-compose.seeder.yml: gluetun v3.41.3 (firewall always on),
the seeder and tracker in its namespace, SEEDER_WG_CONF required; a config
test pins the construction. pnpm-workspace allows node-datachannel's
prebuilt binary and nothing else new.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
MSETTINGS.md | 31+++++++++++++++++++++++++++++++
Mcommon/bin/archilyzer.ts | 24++++++++++++++++++++++++
Mcommon/bin/doctor.test.ts | 26++++++++++++++++++++++++++
Mcommon/bin/doctor.ts | 81++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Acommon/bin/seed.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/seeder.test.ts | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/seeder.ts | 372+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/lastResortSeeder.test.ts | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/lastResortSeeder.ts | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/seederCompose.test.ts | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/seederSettings.test.ts | 40++++++++++++++++++++++++++++++++++++++++
Acommon/lib/seederSettings.ts | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/settingsDocs.ts | 8++++++++
Mcommon/lib/settingsSchema.test.ts | 6+++++-
Mcommon/lib/settingsSchema.ts | 5+++++
Mcommon/package.json | 2++
Mcommon/types/webtorrent-modules.d.ts | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocker-compose.seeder.yml | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Mpnpm-lock.yaml | 1169++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpnpm-workspace.yaml | 10++++++++++
Msettings.json.example | 9+++++++++
22 files changed, 2712 insertions(+), 15 deletions(-)

diff --git a/SETTINGS.md b/SETTINGS.md @@ -48,6 +48,7 @@ A copied example PINS every default it spells — including each lane's `autoQue | [`attribution`](#attribution) | object — see below | | [`archiveOrg`](#archiveorg) | object — see below | | [`publish`](#publish) | object — see below | +| [`seeder`](#seeder) | object — see below | ## `adminTitle` @@ -856,3 +857,33 @@ Default: "homepage": "off" } ``` + +## `seeder` + +The home seeder of last resort (release 21): `archilyzer seed` seeds the sites' playable torrents (`archilyzer media playable`), each one only while no other seeder has it, and only behind a VPN (the docker profile `seeder`, docker-compose.seeder.yml). Not configured until `sites` names one. See SeederSettings and common/controller/seeder.ts. + +#### `seeder` + +| Key | Default | Description | +|---|---|---| +| `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` | `0` | Upload cap across every torrent, KiB/s. 0 = unlimited. | +| `maxConnections` | `50` | Most peer connections per torrent. Clamped to [1, 500]; default 50. | +| `pollSeconds` | `60` | 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` | `600` | 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). | + +Default: + +```json +{ + "sites": [], + "trackers": [], + "maxUploadKiBps": 0, + "maxConnections": 50, + "pollSeconds": 60, + "standbyAfterSeconds": 600, + "bindInterface": "" +} +``` diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts @@ -520,6 +520,30 @@ export const COMMANDS: Command[] = [ }); }, }, + { + path: ["seed"], + usage: + "the home seeder of last resort: seed the playable torrents of settings.seeder.sites, each only while no other seeder has it (standby otherwise, with hysteresis), TCP + WebRTC, no DHT; runs until interrupted — behind the VPN (docker compose profile `seeder`, docker-compose.seeder.yml)", + run: async () => (await import("./seed")).seedMain({ signal: interrupted() }), + }, + { + path: ["tracker"], + usage: + "[--host 127.0.0.1] [--port 8000] the self-hosted tracker (HTTP + WebSocket, no UDP) for the seeder's torrents only — any other infohash is refused; runs until interrupted", + flags: { host: "string", port: "string" }, + run: async ({ flags }) => { + const port = typeof flags.port === "string" ? Number(flags.port) : 8000; + if (!Number.isInteger(port) || port < 0 || port > 65535) { + console.error("tracker: --port must be a port number"); + return 2; + } + return (await import("./seed")).trackerMain({ + host: typeof flags.host === "string" ? flags.host : "127.0.0.1", + port, + signal: interrupted(), + }); + }, + }, // The bins that parse their own flags, run as children with their argv // verbatim (_spawnBin.ts says why). Each row is the whole integration. script(["duplicates"], "duplicate-shorts.ts", diff --git a/common/bin/doctor.test.ts b/common/bin/doctor.test.ts @@ -971,3 +971,29 @@ test("publish: wrangler — the pinned binary or WRANGLER_BIN, run for its major assert.deepEqual(tree(c.root), before); assert.deepEqual(readdirSync(h), [], "nothing under HOME"); }); + +// ── release 21 D4b: the seeder's egress ───────────────────────────────────── + +test("seeder: not configured is a note, and nothing is asked of the network", async () => { + const c = checkout(); + let asked = 0; + const r = await run(c, {}, { seederEgress: async () => (asked++, { host: null, seeder: null }) }); + assert.equal(find(r, "seeder", "seeder")?.status, "info"); + assert.match(find(r, "seeder", "seeder")!.detail, /seeder not configured/); + assert.equal(asked, 0); + assert.equal(r.ok, true); +}); + +test("seeder: the same egress as the host fails; a different one is ok; an unanswered one warns", async () => { + const c = checkout(); + writeFileSync(c.paths.settingsFile, JSON.stringify({ seeder: { sites: ["demo-private"] } })); + const same = await run(c, {}, { seederEgress: async () => ({ host: "198.51.100.7", seeder: "198.51.100.7" }) }); + assert.equal(find(same, "seeder", "egress")?.status, "fail"); + assert.match(find(same, "seeder", "egress")!.detail, /SAME address as this host: it is not behind the VPN/); + assert.equal(same.ok, false); + const vpn = await run(c, {}, { seederEgress: async () => ({ host: "198.51.100.7", seeder: "203.0.113.9" }) }); + assert.equal(find(vpn, "seeder", "egress")?.status, "ok"); + const down = await run(c, {}, { seederEgress: async () => ({ host: "198.51.100.7", seeder: null, seederError: "No such container" }) }); + assert.equal(find(down, "seeder", "egress")?.status, "warn"); + assert.match(find(down, "seeder", "egress")!.detail, /No such container.*profile up/); +}); diff --git a/common/bin/doctor.ts b/common/bin/doctor.ts @@ -18,7 +18,10 @@ // of the source repository's main. It never opens // LMDB (the index is stat'd, not opened), never mkdirs outside the OS temp dir // (one for `wrangler --version`'s debug log, removed after), never writes settings, -// and never binds a port (a port is "in use" when a TCP connect succeeds). The +// and never binds a port (a port is "in use" when a TCP connect succeeds). Its +// one network request is the seeder's egress check (release 21) — this host's +// address and the seeder container's, from one echo — made only when +// `settings.seeder` names sites. The // one process-state change is a chdir around umtool's table, which resolves a // path from the cwd; it is put back before anything else runs. // @@ -99,8 +102,66 @@ export type DoctorDeps = { // Free bytes on the filesystem holding `dir`, or null when it cannot be // asked. Default: statfs. freeBytes?: (dir: string) => number | null; + // The host's egress IP and the seeder container's, asked only when the + // seeder is configured. Default: probeSeederEgress. + seederEgress?: () => Promise<SeederEgressProbe>; }; +// What `archilyzer doctor` learned of the seeder's way out: each egress IP, or +// null with why it could not be asked. +export type SeederEgressProbe = { + host: string | null; + seeder: string | null; + hostError?: string; + seederError?: string; +}; + +// A FAIL when the two are the same address; a warning when either could not +// be asked (the seeder not running is not proof of a leak, nor of its absence). +export function seederEgressVerdict(p: SeederEgressProbe): { status: CheckStatus; detail: string } { + if (!p.seeder) { + return { status: "warn", detail: `the seeder's egress could not be asked (${p.seederError ?? "no answer"}) — is the \`seeder\` profile up? (docker-compose.seeder.yml)` }; + } + if (!p.host) { + return { status: "warn", detail: `the seeder leaves as ${p.seeder}; this host's own egress could not be asked (${p.hostError ?? "no answer"}), so it cannot be compared` }; + } + if (p.host === p.seeder) { + return { status: "fail", detail: `the seeder leaves as ${p.seeder} — the SAME address as this host: it is not behind the VPN. Stop it.` }; + } + return { status: "ok", detail: `the seeder leaves as ${p.seeder}, this host as ${p.host}: behind the VPN` }; +} + +// The address echo both sides ask. One plain-text GET; asked only when the +// seeder is configured. +const EGRESS_ECHO_URL = "https://api.ipify.org"; +// The seeder's container under docker-compose.seeder.yml (project `archilyzer`). +const SEEDER_CONTAINER = "archilyzer-seeder-1"; + +export async function probeSeederEgress(env: NodeJS.ProcessEnv): Promise<SeederEgressProbe> { + const out: SeederEgressProbe = { host: null, seeder: null }; + const ipLike = (s: string) => (/^[0-9a-fA-F.:]{3,45}$/.test(s.trim()) ? s.trim() : null); + try { + const res = await fetch(EGRESS_ECHO_URL, { signal: AbortSignal.timeout(10_000) }); + out.host = ipLike(await res.text()); + if (!out.host) out.hostError = "the echo did not answer with an address"; + } catch (err) { + out.hostError = (err as Error).message; + } + try { + const { dockerBin } = await import("../publish/build"); + const { stdout } = await execFileP( + dockerBin(env), + ["exec", SEEDER_CONTAINER, "node", "-e", `fetch(${JSON.stringify(EGRESS_ECHO_URL)}).then((r) => r.text()).then((t) => process.stdout.write(t))`], + { timeout: 20_000 }, + ); + out.seeder = ipLike(String(stdout)); + if (!out.seeder) out.seederError = "the echo did not answer with an address"; + } catch (err) { + out.seederError = ((err as Error).message.split("\n")[0] ?? "").slice(0, 200); + } + return out; +} + // Where `source publish` would read main from: the variable's path or the // checkout's common dir (null: neither), and main's commit or why it did not // read. @@ -722,6 +783,24 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor ? `main ${published.sourceCommit.slice(0, 12)} as ${published.mirrorHead.slice(0, 12)}, ${published.generatedAt} (${published.files} files)` : `nothing published in ${path.join(publicDir, "source")}`); + // ── seeder (release 21 D4b) ────────────────────────────────────────────── + // The home seeder must leave through the VPN and nothing else. Its egress IP + // (asked from inside its container) against this host's: the same address is + // a FAIL — the seeder would be uploading from home. Not configured (no + // `settings.seeder.sites`) is a note, and asks nothing of the network. + const SD = "seeder"; + { + const { seederConfigured } = await import("../lib/seederSettings"); + const seeder = settings?.seeder; + if (!seeder || !seederConfigured(seeder)) { + add(SD, "seeder", "info", "seeder not configured (settings.seeder.sites is empty)"); + } else { + const probe = await (deps.seederEgress ?? (() => probeSeederEgress(env)))(); + const v = seederEgressVerdict(probe); + add(SD, "egress", v.status, v.detail); + } + } + // ── ports ──────────────────────────────────────────────────────────────── const P = "ports"; const block = await (deps.portBlock ?? (() => worktreePortBlock(root)))(); diff --git a/common/bin/seed.ts b/common/bin/seed.ts @@ -0,0 +1,65 @@ +// `archilyzer seed` and `archilyzer tracker` (release 21 D4b). The work and +// its rules are controller/seeder.ts; the settings are `settings.seeder` +// (SETTINGS.md). Both run until interrupted. +// +// RUN THEM BEHIND THE VPN. The docker profile `seeder` +// (docker-compose.seeder.yml) is the supported way: its namespace has no route +// but the tunnel. Run on a bare host, the seeder uploads from the home address. + +import { getPaths } from "../lib/paths"; +import { getSettings } from "../lib/settings"; +import { seederConfigured } from "../lib/seederSettings"; +import { listSeedTargets, runSeeder, startTracker } from "../controller/seeder"; + +export async function seedMain(opts: { signal: AbortSignal }): Promise<number> { + const settings = getSettings().seeder; + if (!seederConfigured(settings)) { + console.error("seed: the seeder is not configured — name the sites it seeds in settings.seeder.sites (SETTINGS.md)"); + return 2; + } + try { + await runSeeder({ + paths: getPaths(), + settings, + onLog: (line) => console.log(line.replace(/\n$/, "")), + signal: opts.signal, + }); + return 0; + } catch (err) { + console.error(`seed: ${(err as Error).message}`); + return 1; + } +} + +// How long the tracker trusts its list of infohashes before re-reading the +// manifests (an announce arrives every few minutes per peer; a manifest read +// per announce would be a disk walk per announce). +const ALLOWED_TTL_MS = 60_000; + +export async function trackerMain(opts: { host: string; port: number; signal: AbortSignal }): Promise<number> { + const settings = getSettings().seeder; + if (!seederConfigured(settings)) { + console.error("tracker: it tracks the seeder's torrents, and the seeder is not configured — settings.seeder.sites is empty"); + return 2; + } + const paths = getPaths(); + let cache: { at: number; set: Set<string> } | null = null; + const allowed = async () => { + if (!cache || Date.now() - cache.at > ALLOWED_TTL_MS) { + const targets = await listSeedTargets(paths, settings, (l) => console.log(l)); + cache = { at: Date.now(), set: new Set(targets.map((t) => t.infoHash)) }; + } + return cache.set; + }; + try { + const n = (await allowed()).size; + const tracker = await startTracker({ host: opts.host, port: opts.port, allowed, onLog: (l) => console.log(l) }); + console.log(`tracker: ${n} torrent(s) tracked; nothing else is`); + await new Promise<void>((resolve) => opts.signal.addEventListener("abort", () => resolve(), { once: true })); + await tracker.close(); + return 0; + } catch (err) { + console.error(`tracker: ${(err as Error).message}`); + return 1; + } +} diff --git a/common/controller/seeder.test.ts b/common/controller/seeder.test.ts @@ -0,0 +1,192 @@ +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<void> { + 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<void>((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<string>(); + 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<void>((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 }); + } +}); diff --git a/common/controller/seeder.ts b/common/controller/seeder.ts @@ -0,0 +1,372 @@ +// `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. + +/// <reference path="../types/webtorrent-modules.d.ts" /> +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 (`<id>.<ext>`), 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<SeederSettings, "sites">, + onLog: (line: string) => void = () => {}, +): Promise<SeedTarget[]> { + const out = new Map<string, SeedTarget>(); + const slugs = new Set<string>(); + 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.NetworkInterfaceInfo[]> = os.networkInterfaces(), +): boolean { + return (interfaces[name] ?? []).some((a) => !a.internal); +} + +export type ScrapeFn = ( + announce: string, + infoHashes: string[], +) => Promise<Map<string, { complete: number; incomplete: number }>>; + +// One scrape request per tracker, for every infohash at once. +export const scrapeTracker: ScrapeFn = (announce, infoHashes) => + new Promise((resolve, reject) => { + if (infoHashes.length === 0) return resolve(new Map()); + TrackerClient.scrape({ announce, infoHash: infoHashes }, (err, data) => { + if (err) return reject(err); + const rows: ScrapeData[] = + infoHashes.length === 1 ? [data as ScrapeData] : Object.values(data as Record<string, ScrapeData>); + resolve(new Map(rows.map((r) => [r.infoHash, { complete: r.complete, incomplete: r.incomplete }]))); + }); + }); + +// Several trackers' answers for one torrent → one: the most seeders and the +// most leechers any tracker reported (the swarms overlap; a sum would count +// one browser on two trackers twice). Null when no tracker answered. +export function mergeScrapes( + answers: (Map<string, { complete: number; incomplete: number }> | null)[], + infoHash: string, +): { complete: number; incomplete: number } | null { + let out: { complete: number; incomplete: number } | null = null; + for (const a of answers) { + const row = a?.get(infoHash); + if (!row) continue; + out = out + ? { complete: Math.max(out.complete, row.complete), incomplete: Math.max(out.incomplete, row.incomplete) } + : { ...row }; + } + return out; +} + +export type RunSeederOptions = { + paths: Paths; + settings: SeederSettings; + onLog: (line: string) => void; + signal?: AbortSignal; + deps?: { + now?: () => number; + sleep?: (ms: number, signal?: AbortSignal) => Promise<void>; + scrape?: ScrapeFn; + interfaces?: () => NodeJS.Dict<os.NetworkInterfaceInfo[]>; + targets?: () => Promise<SeedTarget[]>; + // Overrides for a test's clock: the poll and the policy's windows, in ms. + pollMs?: number; + policy?: SeederPolicy; + // Stop after this many polls (tests). + maxPolls?: number; + onTransition?: (t: { infoHash: string; to: string; reason: string }) => void; + client?: WebTorrent; + }; +}; + +const defaultSleep = (ms: number, signal?: AbortSignal) => + new Promise<void>((resolve) => { + const t = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(t); + resolve(); + }, { once: true }); + }); + +function addTorrent(client: WebTorrent, buf: Buffer, target: SeedTarget, trackers: string[]): Promise<Torrent> { + return new Promise((resolve, reject) => { + const t = client.add( + buf, + { path: target.dir, skipVerify: true, ...(trackers.length ? { announce: trackers } : {}) }, + (torrent) => resolve(torrent), + ); + t.once("error", reject); + }); +} + +function removeTorrent(client: WebTorrent, torrent: Torrent): Promise<void> { + return new Promise((resolve) => { + client.remove(torrent, { destroyStore: false }, () => resolve()).catch(() => resolve()); + }); +} + +export async function runSeeder(opts: RunSeederOptions): Promise<void> { + const { settings, onLog } = opts; + const d = opts.deps ?? {}; + const now = d.now ?? Date.now; + const sleep = d.sleep ?? defaultSleep; + const scrape = d.scrape ?? scrapeTracker; + const ifaces = d.interfaces ?? (() => os.networkInterfaces()); + const pollMs = d.pollMs ?? settings.pollSeconds * 1000; + const policy: SeederPolicy = d.policy ?? { + standbyAfterSeconds: settings.standbyAfterSeconds, + resumeAfterSeconds: settings.standbyAfterSeconds, + }; + if (!seederConfigured(settings)) { + throw new Error("the seeder is not configured: settings.seeder.sites is empty"); + } + const client = + d.client ?? + new WebTorrent({ + maxConns: settings.maxConnections, + uploadLimit: settings.maxUploadKiBps > 0 ? settings.maxUploadKiBps * 1024 : -1, + dht: false, + lsd: false, + natUpnp: false, + natPmp: false, + utp: false, + webSeeds: false, + }); + onLog(`seeder: WebRTC ${WebTorrent.WEBRTC_SUPPORT ? "available" : "NOT available (TCP peers only)"}; sites ${settings.sites.join(", ")}`); + + type Entry = { target: SeedTarget; memory: SeederMemory; torrent: Torrent | null; trackers: string[]; buf: Buffer }; + const entries = new Map<string, Entry>(); + let tunnelUp = true; + let polls = 0; + + try { + while (!opts.signal?.aborted) { + polls += 1; + // The tunnel, first: no tunnel, no torrents. + if (settings.bindInterface) { + const up = interfacePresent(settings.bindInterface, ifaces()); + if (!up && tunnelUp) { + onLog(`seeder: ${settings.bindInterface} is gone — every torrent stopped until it is back`); + for (const e of entries.values()) { + if (e.torrent) await removeTorrent(client, e.torrent); + e.torrent = null; + } + } else if (up && !tunnelUp) { + onLog(`seeder: ${settings.bindInterface} is back`); + } + tunnelUp = up; + } + + if (tunnelUp) { + // The targets, re-read each poll: a new prepare adds torrents. + const targets = await (d.targets ?? (() => listSeedTargets(opts.paths, settings, onLog)))(); + const wanted = new Set(targets.map((t) => t.infoHash)); + for (const [hash, e] of entries) { + if (!wanted.has(hash)) { + if (e.torrent) await removeTorrent(client, e.torrent); + entries.delete(hash); + onLog(`${e.target.id}: no longer in a playable manifest — dropped`); + } + } + for (const target of targets) { + if (entries.has(target.infoHash)) continue; + // skipVerify trusts the copy, so the copy must at least be whole. + const st = await stat(target.file).catch(() => null); + if (!st || st.size !== target.bytes) { + onLog(`${target.id}: ${target.file} is ${st ? `${st.size} bytes, not ${target.bytes}` : "not there"} — skipped`); + continue; + } + const buf = await readFile(target.torrentPath).catch(() => null); + if (!buf) { + onLog(`${target.id}: ${target.torrentPath} is not there — skipped`); + continue; + } + const own = (await parseTorrent(buf)).announce ?? []; + const trackers = settings.trackers.length ? settings.trackers : own; + entries.set(target.infoHash, { target, memory: initialSeederMemory(now()), torrent: null, trackers, buf }); + } + // Every torrent the machine says to seed and is not seeding, added. + for (const e of entries.values()) { + if (e.memory.state === "seeding" && !e.torrent) { + try { + e.torrent = await addTorrent(client, e.buf, e.target, e.trackers); + onLog(`${e.target.id}: seeding (${e.target.infoHash})`); + } catch (err) { + onLog(`${e.target.id}: could not seed — ${(err as Error).message}`); + } + } + } + + // Observe and decide. + const byTracker = new Map<string, string[]>(); + for (const e of entries.values()) { + for (const tr of e.trackers) byTracker.set(tr, [...(byTracker.get(tr) ?? []), e.target.infoHash]); + } + const answers = new Map<string, Map<string, { complete: number; incomplete: number }> | null>(); + for (const [tr, hashes] of byTracker) { + answers.set(tr, await scrape(tr, hashes).catch((err: Error) => { + onLog(`scrape ${tr}: ${err.message}`); + return null; + })); + } + for (const e of entries.values()) { + const wires = e.torrent?.wires ?? []; + // By peer, not by wire: two peers that dialled each other at once + // hold two wires. + const peers = (seeds: boolean) => + new Set(wires.filter((w) => w.isSeeder === seeds).map((w, i) => w.peerId ?? w.remoteAddress ?? `wire${i}`)).size; + const o: SwarmObservation = { + at: now(), + scrape: mergeScrapes(e.trackers.map((tr) => answers.get(tr) ?? null), e.target.infoHash), + selfCounted: e.memory.state === "seeding" && !!e.torrent, + connectedSeeds: peers(true), + connectedLeechers: peers(false), + }; + const step = stepSeeder(e.memory, o, policy); + e.memory = step.memory; + if (!step.transition) continue; + onLog(`${e.target.id}: ${step.transition.from} → ${step.transition.to} — ${step.transition.reason} (${step.seen})`); + d.onTransition?.({ infoHash: e.target.infoHash, to: step.transition.to, reason: step.transition.reason }); + if (step.transition.to === "standby" && e.torrent) { + await removeTorrent(client, e.torrent); + e.torrent = null; + } else if (step.transition.to === "seeding" && !e.torrent) { + try { + e.torrent = await addTorrent(client, e.buf, e.target, e.trackers); + } catch (err) { + onLog(`${e.target.id}: could not resume — ${(err as Error).message}`); + } + } + } + } + if (d.maxPolls && polls >= d.maxPolls) break; + await sleep(pollMs, opts.signal); + } + } finally { + if (!d.client) await new Promise<void>((resolve) => client.destroy(() => resolve())); + } +} + +// --- the tracker ------------------------------------------------------------ + +export type RunningTracker = { + port: number; + host: string; + // What the tracker itself knows of one swarm (null: never announced). + swarm: (infoHash: string) => { complete: number; incomplete: number } | null; + close: () => Promise<void>; +}; + +export async function startTracker(opts: { + host: string; + port: number; + // The infohashes it tracks; anything else is refused. Re-read on every + // announce through this function, so a new prepare needs no restart. + allowed: () => Promise<Set<string>> | Set<string>; + onLog?: (line: string) => void; +}): Promise<RunningTracker> { + const server = new TrackerServer({ + udp: false, + http: true, + ws: true, + stats: false, + trustProxy: false, + filter: (infoHash, _params, cb) => { + Promise.resolve(opts.allowed()) + .then((set) => cb(set.has(infoHash) ? null : new Error("this tracker does not track that torrent"))) + .catch((err: Error) => cb(err)); + }, + }); + server.on("error", (err: Error) => opts.onLog?.(`tracker: ${err.message}`)); + server.on("warning", (err: Error) => opts.onLog?.(`tracker warning: ${err.message}`)); + await new Promise<void>((resolve) => server.listen(opts.port, opts.host, () => resolve())); + const address = server.http?.address(); + const port = address?.port ?? opts.port; + opts.onLog?.(`tracker: http://${opts.host}:${port}/announce and ws://${opts.host}:${port}`); + return { + port, + host: opts.host, + swarm: (infoHash) => { + const t = server.torrents[infoHash]; + return t ? { complete: t.complete, incomplete: t.incomplete } : null; + }, + close: () => new Promise<void>((resolve) => server.close(() => resolve())), + }; +} diff --git a/common/lib/lastResortSeeder.test.ts b/common/lib/lastResortSeeder.test.ts @@ -0,0 +1,162 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + initialSeederMemory, + otherSeeders, + stepSeeder, + type SeederMemory, + type SeederPolicy, + type SeederTransition, + type SwarmObservation, +} from "./lastResortSeeder"; + +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test lib/lastResortSeeder.test.ts +// +// The peer-of-last-resort state machine against a fake scrape source: a +// scripted swarm, polled every POLL seconds, with the seeder's own +// announce state fed back in (counted in `complete` while it seeds). + +const POLL = 30; +const POLICY: SeederPolicy = { standbyAfterSeconds: 120, resumeAfterSeconds: 300 }; + +type Tick = { + // Other seeders and leechers the tracker knows (the seeder itself is added + // by the harness while it seeds). null = the scrape failed. + others: number | null; + leechers?: number; + connectedSeeds?: number; + connectedLeechers?: number; +}; + +// Run a script; return each poll's state and every transition with its time. +function run(script: Tick[], policy = POLICY, start: SeederMemory = initialSeederMemory(0)) { + let mem = start; + const states: string[] = []; + const transitions: (SeederTransition & { at: number })[] = []; + script.forEach((t, i) => { + const at = (i + 1) * POLL * 1000; + const self = mem.state === "seeding"; + const o: SwarmObservation = { + at, + scrape: t.others === null ? null : { complete: t.others + (self ? 1 : 0), incomplete: t.leechers ?? 0 }, + selfCounted: self, + connectedSeeds: t.connectedSeeds ?? 0, + connectedLeechers: t.connectedLeechers ?? 0, + }; + const step = stepSeeder(mem, o, policy); + if (step.transition) transitions.push({ ...step.transition, at: at / 1000 }); + mem = step.memory; + states.push(mem.state === "seeding" ? "S" : "-"); + }); + return { states: states.join(""), transitions, memory: mem }; +} + +const rep = (n: number, t: Tick) => Array.from({ length: n }, () => t); + +test("alone: it seeds and stays seeding", () => { + const r = run(rep(20, { others: 0, leechers: 1 })); + assert.equal(r.states, "S".repeat(20)); + assert.deepEqual(r.transitions, []); +}); + +test("others present for standbyAfterSeconds → standby, not before", () => { + // 120 s at a 30 s poll: the run starts at the 1st poll (t=30) and is long + // enough at the 5th (t=150). + const r = run(rep(6, { others: 1 })); + assert.equal(r.states, "SSSS--"); + assert.equal(r.transitions.length, 1); + assert.equal(r.transitions[0].to, "standby"); + assert.equal(r.transitions[0].at, 150); + assert.match(r.transitions[0].reason, /1 other seeder\(s\) on every poll for 120s/); +}); + +test("hysteresis on the standby edge: one poll with no other seeder restarts the clock", () => { + const r = run([...rep(3, { others: 1 }), { others: 0 }, ...rep(4, { others: 1 }), ...rep(2, { others: 1 })]); + // The clock restarts at poll 5 (t=150) and is long enough at poll 9 (t=270). + assert.equal(r.transitions[0]?.at, 270); +}); + +test("its own announce is not another seeder", () => { + // The scrape counts it (complete = 1) while it seeds: that is no one else. + const r = run(rep(10, { others: 0 })); + assert.equal(r.states, "S".repeat(10)); +}); + +test("standby → seeding at once when no other seeder remains and a leecher waits", () => { + const r = run([...rep(5, { others: 1 }), { others: 1 }, { others: 0, leechers: 1 }]); + assert.equal(r.transitions.map((t) => t.to).join(","), "standby,seeding"); + assert.equal(r.transitions[1].at, 210); + assert.match(r.transitions[1].reason, /no other seeder and 1 leecher\(s\) waiting/); +}); + +test("a connected leecher with no other source counts as waiting", () => { + const start: SeederMemory = { state: "standby", since: 0, othersSince: null, aloneSince: null, silentSince: null }; + const r = run([{ others: 0, leechers: 0, connectedLeechers: 1 }], POLICY, start); + assert.equal(r.transitions[0]?.to, "seeding"); +}); + +test("hysteresis on the resume edge: alone with nobody waiting resumes after resumeAfterSeconds", () => { + const start: SeederMemory = { state: "standby", since: 0, othersSince: null, aloneSince: null, silentSince: null }; + // 300 s at 30 s: the run starts at t=30 and is long enough at t=330 (poll 11). + const r = run(rep(12, { others: 0, leechers: 0 }), POLICY, start); + assert.equal(r.transitions.length, 1); + assert.equal(r.transitions[0].at, 330); + assert.match(r.transitions[0].reason, /no other seeder on every poll for 300s/); + // A poll that sees a seeder again restarts that clock. + const r2 = run([...rep(5, { others: 0 }), { others: 1 }, ...rep(5, { others: 0 })], POLICY, start); + assert.deepEqual(r2.transitions, []); +}); + +test("a flapping swarm does not flap the seeder", () => { + // Another seeder that comes and goes every other poll, with a leecher. + const flap = Array.from({ length: 40 }, (_, i) => ({ others: i % 2 === 0 ? 1 : 0, leechers: 1 })); + const r = run(flap); + // Never two polls in a row with others → the standby clock never fills. + assert.deepEqual(r.transitions, []); + assert.equal(r.states, "S".repeat(40)); +}); + +test("after a resume the standby edge needs its whole window again", () => { + const r = run([...rep(5, { others: 1 }), { others: 0, leechers: 1 }, ...rep(5, { others: 1 })]); + // standby at t=150, seeding at t=180, standby again only at t=180+150=330. + assert.deepEqual(r.transitions.map((t) => [t.to, t.at]), [["standby", 150], ["seeding", 180], ["standby", 330]]); +}); + +test("a failed scrape decides nothing while seeding", () => { + const r = run([...rep(3, { others: 1 }), { others: null }, ...rep(5, { others: 1 })]); + // The silent poll breaks the others-run: standby needs 120 s from t=150. + assert.deepEqual(r.transitions.map((t) => t.at), [270]); +}); + +test("in standby, trackers silent for resumeAfterSeconds → resume (assume no other seeder)", () => { + const start: SeederMemory = { state: "standby", since: 0, othersSince: null, aloneSince: null, silentSince: null }; + const r = run(rep(11, { others: null }), POLICY, start); + assert.equal(r.transitions[0]?.to, "seeding"); + assert.equal(r.transitions[0]?.at, 330); + assert.match(r.transitions[0]?.reason ?? "", /no tracker answered/); +}); + +test("a connected seed is another source even when the scrape misses it", () => { + assert.equal( + otherSeeders({ at: 0, scrape: { complete: 1, incomplete: 0 }, selfCounted: true, connectedSeeds: 2, connectedLeechers: 0 }), + 2, + ); + assert.equal(otherSeeders({ at: 0, scrape: null, selfCounted: false, connectedSeeds: 0, connectedLeechers: 3 }), null); + assert.equal(otherSeeders({ at: 0, scrape: null, selfCounted: false, connectedSeeds: 1, connectedLeechers: 0 }), 1); + assert.equal(otherSeeders({ at: 0, scrape: { complete: 0, incomplete: 0 }, selfCounted: true, connectedSeeds: 0, connectedLeechers: 0 }), 0); + const r = run(rep(6, { others: 0, connectedSeeds: 1 })); + assert.equal(r.transitions[0]?.to, "standby"); +}); + +test("the rollout's check: a second browser seeds → standby; it closes → resume", () => { + // Step 7 of the plan's rollout, as a script: a viewer finishes and seeds, + // the home seeder stands by; the viewer leaves and another viewer arrives. + const r = run([ + ...rep(2, { others: 0, leechers: 1, connectedLeechers: 1 }), + ...rep(6, { others: 1, leechers: 0, connectedSeeds: 1 }), + ...rep(2, { others: 1 }), + { others: 0, leechers: 1 }, + ]); + assert.deepEqual(r.transitions.map((t) => t.to), ["standby", "seeding"]); + assert.equal(r.memory.state, "seeding"); +}); diff --git a/common/lib/lastResortSeeder.ts b/common/lib/lastResortSeeder.ts @@ -0,0 +1,153 @@ +// THE PEER OF LAST RESORT (release 21 D4b): when the home seeder seeds a +// torrent, and when it stands by. +// +// The home seeder exists so that a torrent with nobody else seeding it still +// plays. It should not be the swarm's workhorse: every byte it uploads goes +// through a VPN on a home connection. So per torrent it SEEDS only while no +// other seeder is known, and STANDS BY (stops announcing, closes its peers, +// keeps the data) while others are. This module is the decision and nothing +// else — pure, clocked by the caller, so every edge is testable against a +// fake scrape (lastResortSeeder.test.ts); `archilyzer seed` +// (controller/seeder.ts) observes the swarm and applies what it returns. +// +// WHAT IT SEES, per torrent per poll (`SwarmObservation`): +// - the trackers' scrape: `complete` (seeders) and `incomplete` (leechers), +// or null when no tracker answered; +// - whether the seeder itself is counted in that `complete` (it is while it +// announces as a seeder; once it has sent `stopped`, it is not); +// - its own wires: connected peers that have every piece (other sources) +// and connected peers that do not (leechers). +// +// THE EDGES, both with hysteresis so a swarm hovering at one seeder does not +// make it flap: +// seeding → standby other seeders seen on EVERY poll for +// `standbyAfterSeconds` (the clock restarts the moment +// a poll sees none); +// standby → seeding no other seeder AND a leecher waiting (the tracker's +// `incomplete`, or a wire): at once — someone is trying +// to play it now; +// no other seeder on every poll for `resumeAfterSeconds` +// with nobody waiting: so the torrent is never left with +// no seeder for longer than that. +// A transition restarts both clocks, so the reverse edge needs its full +// window again. A failed scrape decides nothing (the state holds) — except +// that a seeder in standby whose trackers have been silent for +// `resumeAfterSeconds` resumes: it cannot know there is another seeder, and +// being the last resort means assuming there is not. +// +// Known cost (the plan's): if the last other seeder leaves mid-stream, a +// viewer stalls until the next poll sees it. + +export type SeederTorrentState = "seeding" | "standby"; + +export type SwarmObservation = { + // Milliseconds (the caller's clock). + at: number; + scrape: { complete: number; incomplete: number } | null; + // Whether `scrape.complete` includes the seeder itself. + selfCounted: boolean; + // Connected peers holding every piece, and the rest. + connectedSeeds: number; + connectedLeechers: number; +}; + +export type SeederPolicy = { + standbyAfterSeconds: number; + resumeAfterSeconds: number; +}; + +export type SeederMemory = { + state: SeederTorrentState; + // When the current state began. + since: number; + // Start of the current run of polls that saw other seeders / saw none / + // got no scrape. Null when the last poll broke the run. + othersSince: number | null; + aloneSince: number | null; + silentSince: number | null; +}; + +export type SeederTransition = { + from: SeederTorrentState; + to: SeederTorrentState; + reason: string; +}; + +export type SeederStep = { + memory: SeederMemory; + transition: SeederTransition | null; + // What this poll saw, in words, for the log. + seen: string; +}; + +// A torrent starts SEEDING: on boot the seeder knows nothing about the swarm, +// and the safe assumption for a last resort is that it is the only one. +export function initialSeederMemory(at: number): SeederMemory { + return { state: "seeding", since: at, othersSince: null, aloneSince: null, silentSince: null }; +} + +// Other seeders this poll saw: the scrape's count without the seeder itself, +// or the seeds connected to it, whichever is more (a seed on another tracker +// the scrape did not ask is still a source). Null when there was no scrape +// and no connected seed — unknown, not zero. +export function otherSeeders(o: SwarmObservation): number | null { + const fromScrape = o.scrape ? Math.max(0, o.scrape.complete - (o.selfCounted ? 1 : 0)) : null; + if (fromScrape === null) return o.connectedSeeds > 0 ? o.connectedSeeds : null; + return Math.max(fromScrape, o.connectedSeeds); +} + +function leechersWaiting(o: SwarmObservation): number { + return Math.max(o.scrape?.incomplete ?? 0, o.connectedLeechers); +} + +const secs = (ms: number) => `${Math.round(ms / 1000)}s`; + +export function stepSeeder( + memory: SeederMemory, + o: SwarmObservation, + policy: SeederPolicy, +): SeederStep { + const others = otherSeeders(o); + const waiting = leechersWaiting(o); + const seen = + others === null + ? `no scrape answered; ${o.connectedSeeds} seed(s) and ${o.connectedLeechers} leecher(s) connected` + : `${others} other seeder(s), ${waiting} leecher(s)`; + + // Advance the three run clocks. + const m: SeederMemory = { + ...memory, + othersSince: others !== null && others > 0 ? (memory.othersSince ?? o.at) : null, + aloneSince: others === 0 ? (memory.aloneSince ?? o.at) : null, + silentSince: others === null ? (memory.silentSince ?? o.at) : null, + }; + const standbyMs = policy.standbyAfterSeconds * 1000; + const resumeMs = policy.resumeAfterSeconds * 1000; + const go = (to: SeederTorrentState, reason: string): SeederStep => ({ + memory: { state: to, since: o.at, othersSince: null, aloneSince: null, silentSince: null }, + transition: { from: m.state, to, reason }, + seen, + }); + + if (m.state === "seeding") { + if (m.othersSince !== null && o.at - m.othersSince >= standbyMs) { + return go( + "standby", + `${others} other seeder(s) on every poll for ${secs(o.at - m.othersSince)} (standby after ${policy.standbyAfterSeconds}s)`, + ); + } + return { memory: m, transition: null, seen }; + } + + // standby + if (others === 0 && waiting > 0) { + return go("seeding", `no other seeder and ${waiting} leecher(s) waiting`); + } + if (m.aloneSince !== null && o.at - m.aloneSince >= resumeMs) { + return go("seeding", `no other seeder on every poll for ${secs(o.at - m.aloneSince)} (resume after ${policy.resumeAfterSeconds}s)`); + } + if (m.silentSince !== null && o.at - m.silentSince >= resumeMs) { + return go("seeding", `no tracker answered for ${secs(o.at - m.silentSince)}: assuming no other seeder`); + } + return { memory: m, transition: null, seen }; +} diff --git a/common/lib/seederCompose.test.ts b/common/lib/seederCompose.test.ts @@ -0,0 +1,80 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test lib/seederCompose.test.ts +// +// THE KILL SWITCH IS THE CONSTRUCTION (release 21 D4b), so the construction is +// what this pins, in docker-compose.seeder.yml: the seeder and the tracker +// have no network of their own (they live in the VPN container's namespace), +// publish nothing, and the VPN container's firewall is on and its WireGuard +// config is the operator's — required, never defaulted, never baked. + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const TEXT = readFileSync(path.join(REPO, "docker-compose.seeder.yml"), "utf8"); + +// The services block as name → its lines (comments dropped). Two-space +// service keys under a top-level `services:`; enough YAML for this file. +function services(text: string): Map<string, string[]> { + const out = new Map<string, string[]>(); + let inServices = false; + let current: string | null = null; + for (const raw of text.split("\n")) { + const line = raw.replace(/\s+#.*$/, ""); + if (/^\S/.test(line)) { + inServices = /^services:\s*$/.test(line); + current = null; + continue; + } + if (!inServices || !line.trim() || /^\s*#/.test(raw)) continue; + const key = /^ {2}([a-z0-9-]+):\s*$/.exec(line); + if (key) { + current = key[1]; + out.set(current, []); + } else if (current) { + out.get(current)!.push(line); + } + } + return out; +} + +const S = services(TEXT); +const block = (name: string) => (S.get(name) ?? []).join("\n"); + +test("the profile has a VPN container, the seeder and the tracker — all behind profile `seeder`", () => { + assert.deepEqual([...S.keys()].sort(), ["seeder", "seeder-vpn", "tracker"]); + for (const name of S.keys()) assert.match(block(name), /^\s{4}profiles: \[seeder\]$/m, name); +}); + +test("the seeder and the tracker share the VPN container's network namespace and nothing else", () => { + for (const name of ["seeder", "tracker"]) { + const b = block(name); + assert.match(b, /^\s{4}network_mode: service:seeder-vpn$/m, `${name}: network_mode`); + assert.doesNotMatch(b, /^\s{4}networks:/m, `${name}: no networks of its own`); + assert.doesNotMatch(b, /^\s{4}ports:/m, `${name}: publishes nothing`); + assert.doesNotMatch(b, /^\s{4}network_mode: (host|bridge)/m, name); + // It waits for the tunnel to be up, not merely started. + assert.match(b, /seeder-vpn:\n\s+condition: service_healthy/, `${name}: waits for a healthy tunnel`); + // Read-only corpus: the seeder writes nothing. + for (const vol of b.matchAll(/^\s{6}- [^\s:]+:\/data\/[^\s:]+$/gm)) assert.fail(`${name}: writable mount ${vol[0]}`); + assert.match(b, /- corpus:\/data\/transcripts:ro$/m, `${name}: the corpus, read-only`); + } +}); + +test("the VPN container: WireGuard, the firewall on, the operator's config required and read-only, no port published", () => { + const b = block("seeder-vpn"); + assert.match(b, /VPN_TYPE: wireguard/); + assert.match(b, /VPN_SERVICE_PROVIDER: custom/); + assert.match(b, /image: qmcgaw\/gluetun:v\d+\.\d+\.\d+$/m, "a pinned release"); + assert.doesNotMatch(TEXT, /FIREWALL: "?off/i, "nothing turns a firewall off"); + assert.match(b, /WIREGUARD_CONF_SECRETFILE: \/gluetun\/wireguard\/wg0\.conf/); + assert.doesNotMatch(b, /^\s{4}ports:/m); + assert.doesNotMatch(b, /network_mode/); + // `${VAR:?…}`: compose refuses to start without it; there is no default. + assert.match(b, /- \$\{SEEDER_WG_CONF:\?[^}]+\}:\/gluetun\/wireguard\/wg0\.conf:ro/); + assert.doesNotMatch(TEXT, /PrivateKey\s*=|WIREGUARD_PRIVATE_KEY:\s*\S/, "no key in the file"); + // Inbound only when the operator forwards a port; empty by default. + assert.match(b, /FIREWALL_VPN_INPUT_PORTS: \$\{SEEDER_VPN_INPUT_PORTS:-\}/); +}); diff --git a/common/lib/seederSettings.test.ts b/common/lib/seederSettings.test.ts @@ -0,0 +1,40 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { DEFAULT_SEEDER_SETTINGS, sanitizeSeeder, seederConfigured } from "./seederSettings"; + +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test lib/seederSettings.test.ts + +test("absent or garbage is the default, which is not configured", () => { + for (const v of [undefined, null, "x", 3, [], { sites: "jeralyzer-private" }]) { + const s = sanitizeSeeder(v); + assert.deepEqual(s.sites, []); + assert.equal(seederConfigured(s), false, JSON.stringify(v)); + } + assert.deepEqual(sanitizeSeeder({}), DEFAULT_SEEDER_SETTINGS); +}); + +test("sites and trackers keep only well-formed, distinct entries", () => { + const s = sanitizeSeeder({ + sites: ["jeralyzer-private", " jeralyzer-private ", "BAD ID", 3, "../x"], + trackers: ["wss://tracker.example/announce", "http://10.0.0.2:8000/announce", "udp://t.example:6969", "file:///etc/passwd", "not a url"], + }); + assert.deepEqual(s.sites, ["jeralyzer-private"]); + assert.deepEqual(s.trackers, ["wss://tracker.example/announce", "http://10.0.0.2:8000/announce", "udp://t.example:6969"]); + assert.equal(seederConfigured(s), true); +}); + +test("numbers are clamped, and a bad interface name reads as unset", () => { + const s = sanitizeSeeder({ + maxUploadKiBps: -5, + maxConnections: 10_000, + pollSeconds: 1, + standbyAfterSeconds: 10, + bindInterface: "wg0; rm -rf /", + }); + assert.equal(s.maxUploadKiBps, 0); + assert.equal(s.maxConnections, 500); + assert.equal(s.pollSeconds, 10); + assert.equal(s.standbyAfterSeconds, 60); + assert.equal(s.bindInterface, ""); + assert.equal(sanitizeSeeder({ bindInterface: " wg0 " }).bindInterface, "wg0"); +}); diff --git a/common/lib/seederSettings.ts b/common/lib/seederSettings.ts @@ -0,0 +1,96 @@ +// 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<SeederSettings> = { + 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<string, unknown>; + 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; +} diff --git a/common/lib/settingsDocs.ts b/common/lib/settingsDocs.ts @@ -21,6 +21,7 @@ import { PUBLISH_SETTINGS_FIELD_DOCS, DIGEST_SETTINGS_FIELD_DOCS, SAVED_VIDEO_BACKUP_SETTINGS_FIELD_DOCS, + SEEDER_SETTINGS_FIELD_DOCS, SOCIAL_LINK_FIELD_DOCS, SOCIAL_SETTINGS_FIELD_DOCS, X_SOCIAL_SETTINGS_FIELD_DOCS, @@ -252,6 +253,13 @@ export function blockTables(d: SiteSettings): Partial<Record<keyof SiteSettings, defaults: fromObject(d.publish), }, ], + seeder: [ + { + path: "seeder", + docs: SEEDER_SETTINGS_FIELD_DOCS, + defaults: fromObject(d.seeder), + }, + ], }; } diff --git a/common/lib/settingsSchema.test.ts b/common/lib/settingsSchema.test.ts @@ -30,6 +30,7 @@ import type { } from "../ytdlp/downloadFormat"; import type { StorageSettings } from "./storageLocations"; import type { ArchiveOrgFetchSettings } from "./archiveOrgTorrent"; +import type { SeederSettings } from "./seederSettings"; import type { AttributionSettings, BackfillSettings, @@ -103,13 +104,15 @@ type PreSchemaSiteSettings = { archiveOrg: ArchiveOrgFetchSettings; // The publish lane (release 18). publish: PublishSettings; + // The home seeder of last resort (release 21 D4b). + seeder: SeederSettings; }; // Bracketed so the conditional does not distribute (see commit 8c43231). type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false; const shapeUnchanged: Same<SiteSettings, PreSchemaSiteSettings> = true; -test("SiteSettings keeps its 36 fields, in file order", () => { +test("SiteSettings keeps its 37 fields, in file order", () => { assert.equal(shapeUnchanged, true); assert.deepEqual(Object.keys(siteSettingsSchema.shape), [ "adminTitle", @@ -148,6 +151,7 @@ test("SiteSettings keeps its 36 fields, in file order", () => { "attribution", "archiveOrg", "publish", + "seeder", ]); // A parsed object carries every key, in that order — writeSettings writes // exactly this, so the order is the on-disk order. diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts @@ -76,6 +76,8 @@ import { DEFAULT_ARCHIVE_ORG_FETCH_SETTINGS, type ArchiveOrgFetchSettings, } from "./archiveOrgTorrent"; +import { sanitizeSeeder, type SeederSettings } from "./seederSettings"; +export { SEEDER_SETTINGS_FIELD_DOCS, type SeederSettings } from "./seederSettings"; import { DEFAULT_COOKIE_MODE, isCookieMode, @@ -1822,6 +1824,9 @@ export const siteSettingsSchema = z.object({ publish: settingsField((v): PublishSettings => sanitizePublish(v)).describe( "The publish LANE (release 18): a runner that, when the index is stale, updates it and then builds — and, where a site's own `publish.auto` (site.json) says so, deploys — what changed, one stage at a time on the `publish` queue. OFF by default; the manual stages (Publish now, Build, Deploy) work either way. See PublishSettings and common/publish/publishRunner.ts.", ), + seeder: settingsField((v): SeederSettings => sanitizeSeeder(v)).describe( + "The home seeder of last resort (release 21): `archilyzer seed` seeds the sites' playable torrents (`archilyzer media playable`), each one only while no other seeder has it, and only behind a VPN (the docker profile `seeder`, docker-compose.seeder.yml). Not configured until `sites` names one. See SeederSettings and common/controller/seeder.ts.", + ), }); export type SiteSettings = z.infer<typeof siteSettingsSchema>; diff --git a/common/package.json b/common/package.json @@ -58,6 +58,7 @@ "@sindresorhus/slugify": "^3.0.0", "@tanstack/react-query": "^5.99.1", "@tanstack/react-virtual": "^3.13.0", + "bittorrent-tracker": "^11.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -78,6 +79,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", + "webtorrent": "^3.0.21", "zod": "^4.3.6" }, "peerDependencies": { diff --git a/common/types/webtorrent-modules.d.ts b/common/types/webtorrent-modules.d.ts @@ -20,6 +20,93 @@ declare module "create-torrent" { ): void; } +declare module "webtorrent" { + import type { EventEmitter } from "node:events"; + export type WebTorrentOptions = { + maxConns?: number; + uploadLimit?: number; + downloadLimit?: number; + dht?: boolean | Record<string, unknown>; + lsd?: boolean; + natUpnp?: boolean; + natPmp?: boolean; + utp?: boolean; + utPex?: boolean; + webSeeds?: boolean; + tracker?: boolean | Record<string, unknown>; + torrentPort?: number; + }; + export type AddOptions = { + path?: string; + announce?: string[]; + skipVerify?: boolean; + private?: boolean; + }; + export interface Wire { + isSeeder: boolean; + peerId?: string; + remoteAddress?: string; + type?: string; + } + export interface Torrent extends EventEmitter { + infoHash: string; + name: string; + ready: boolean; + done: boolean; + progress: number; + downloaded: number; + uploaded: number; + numPeers: number; + wires: Wire[]; + destroyed: boolean; + destroy(opts?: { destroyStore?: boolean } | null, cb?: (err?: Error | null) => void): void; + } + export default class WebTorrent extends EventEmitter { + static WEBRTC_SUPPORT: boolean; + static VERSION: string; + constructor(opts?: WebTorrentOptions); + torrents: Torrent[]; + torrentPort: number; + destroyed: boolean; + add(torrentId: Uint8Array | string, opts?: AddOptions, onTorrent?: (t: Torrent) => void): Torrent; + seed(input: string | Uint8Array, opts?: Record<string, unknown>, onSeed?: (t: Torrent) => void): Torrent; + remove(torrentId: Torrent | string, opts?: { destroyStore?: boolean } | null, cb?: (err?: Error | null) => void): Promise<void>; + throttleUpload(rate: number): void; + destroy(cb?: (err?: Error | null) => void): void; + address(): { address: string; family: string; port: number } | null; + } +} + +declare module "bittorrent-tracker" { + import type { EventEmitter } from "node:events"; + export type ScrapeData = { announce: string; infoHash: string; complete: number; incomplete: number; downloaded: number }; + export class Client extends EventEmitter { + static scrape( + opts: { announce: string; infoHash: string | string[] }, + cb: (err: Error | null, data: ScrapeData | Record<string, ScrapeData>) => void, + ): Client; + destroy(cb?: () => void): void; + } + export type ServerOptions = { + http?: boolean; + udp?: boolean; + ws?: boolean; + stats?: boolean; + trustProxy?: boolean; + interval?: number; + filter?: (infoHash: string, params: unknown, cb: (err?: Error | null) => void) => void; + }; + export class Server extends EventEmitter { + constructor(opts?: ServerOptions); + torrents: Record<string, { complete: number; incomplete: number; peers: unknown }>; + listen(port: number, hostname: string, cb?: () => void): void; + address(): { address: string; port: number } | null; + http: { address(): { address: string; port: number } | null } | null; + close(cb?: () => void): void; + } + export default Client; +} + declare module "parse-torrent" { export type ParsedTorrent = { infoHash: string; diff --git a/docker-compose.seeder.yml b/docker-compose.seeder.yml @@ -0,0 +1,108 @@ +# The home seeder, behind a VPN, with no way around it (release 21 D4b). +# +# SEEDER_WG_CONF=/abs/path/to/wg0.conf \ +# docker compose -f docker-compose.yml -f docker-compose.seeder.yml --profile seeder up -d +# +# `archilyzer seed` is the peer of last resort for the sites' playable +# torrents (settings.seeder; SETTINGS.md): it seeds a torrent only while no +# other seeder has it. Every byte it uploads leaves through the tunnel below. +# +# THE KILL SWITCH IS THE CONSTRUCTION, not a setting: +# +# * `seeder-vpn` is gluetun: a WireGuard client whose firewall drops every +# packet that is not in the tunnel (or to the tunnel's own endpoint). Tunnel +# down = no network, never the home connection. +# * `seeder` and `tracker` have NO network of their own: `network_mode: +# service:seeder-vpn` puts them in that container's network namespace, so +# the only interfaces they can see are the tunnel and loopback (plus +# gluetun's own, which its firewall closes). WebRTC gathers ICE candidates +# from what it can see, so a browser peer is never offered the home address. +# * Neither publishes a port. Inbound connections (a browser or a desktop +# client dialing the seeder, a browser reaching the tracker) need the VPN +# provider's port forwarding — gluetun's FIREWALL_VPN_INPUT_PORTS. Whether +# to rely on that, or on a TURN relay, is the operator's ruling (the plan's +# "risk to measure first"); nothing here opens one by default. +# * `seeder.bindInterface` in settings.json (e.g. "tun0" or "wg0", whatever +# gluetun names it) makes `archilyzer seed` refuse to start, and stop every +# torrent, while that interface is gone — the same rule, a second time, in +# the app. +# +# THE WIREGUARD CONFIG IS YOURS. `SEEDER_WG_CONF` is the absolute path of a +# wg-quick file from your VPN provider (an [Interface] with your PrivateKey and +# Address, a [Peer] with the endpoint). There is no default and nothing here +# invents one: compose refuses to start the profile without it. It is mounted +# read-only and never copied into an image. +# +# `archilyzer doctor` compares the seeder's egress IP with the host's and fails +# if they match ("seeder" section); it says "seeder not configured" while +# settings.seeder.sites is empty. + +services: + seeder-vpn: + profiles: [seeder] + # Pinned. gluetun's firewall — always on in v3, there is no switch for it — + # is what makes the tunnel the only way out of this namespace. + image: qmcgaw/gluetun:v3.41.3 + restart: unless-stopped + cap_add: [NET_ADMIN] + devices: + - /dev/net/tun:/dev/net/tun + environment: + VPN_SERVICE_PROVIDER: custom + VPN_TYPE: wireguard + WIREGUARD_CONF_SECRETFILE: /gluetun/wireguard/wg0.conf + # Inbound through the tunnel, when the provider forwards a port to you: + # the seeder's torrent port and the tracker's. Empty = none. + FIREWALL_VPN_INPUT_PORTS: ${SEEDER_VPN_INPUT_PORTS:-} + TZ: ${TZ:-UTC} + volumes: + - ${SEEDER_WG_CONF:?set SEEDER_WG_CONF to your VPN provider's wg-quick file}:/gluetun/wireguard/wg0.conf:ro + + # The app image (docker-compose.yml's), run as `archilyzer seed`. YAML + # anchors do not cross files, so the few keys it needs are spelled here. + seeder: + image: archilyzer:${ARCHILYZER_TAG:-local} + profiles: [seeder] + restart: unless-stopped + command: ["pnpm", "--silent", "archilyzer", "seed"] + network_mode: service:seeder-vpn + depends_on: + seeder-vpn: + condition: service_healthy + env_file: + - path: .env + required: false + environment: + TRANSCRIPTS_DIR: /data/transcripts + SETTINGS_FILE: /data/config/settings.json + volumes: + # Read-only: the seeder reads site.json, the playable manifests and the + # copies; it writes nothing to the corpus. A playable root on another + # drive (beside a saved-video store moved off the corpus volume) needs + # its own read-only bind mount at the same absolute path, in a + # docker-compose.override.yml. + - corpus:/data/transcripts:ro + - config:/data/config:ro + + # The pilot's self-hosted tracker (WebSocket + HTTP), in the same namespace, + # so its announces and its address are the tunnel's too. TLS (wss://) is + # whatever terminates in front of the forwarded port. + tracker: + image: archilyzer:${ARCHILYZER_TAG:-local} + profiles: [seeder] + restart: unless-stopped + command: ["pnpm", "--silent", "archilyzer", "tracker", "--host", "0.0.0.0", "--port", "${SEEDER_TRACKER_PORT:-8000}"] + network_mode: service:seeder-vpn + depends_on: + seeder-vpn: + condition: service_healthy + env_file: + - path: .env + required: false + environment: + TRANSCRIPTS_DIR: /data/transcripts + SETTINGS_FILE: /data/config/settings.json + volumes: + # It reads the playable manifests to know which infohashes it tracks. + - corpus:/data/transcripts:ro + - config:/data/config:ro diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **A home seeder of last resort, behind a VPN.** `archilyzer seed` seeds the playable torrents of the sites named in the new `settings.seeder` (`sites`, `trackers`, `maxUploadKiBps`, `maxConnections`, `pollSeconds`, `standbyAfterSeconds`, `bindInterface`; SETTINGS.md) to desktop clients over TCP and to browsers over WebRTC — but each torrent only while no other seeder has it: other seeders seen on every poll for `standbyAfterSeconds` puts that torrent on standby (it stops announcing and closes its peers, keeping the data), and it comes back at once when a leecher is waiting with no other source, or after the same window with no other seeder. Every change is logged with its reason. No DHT, no local discovery, no UPnP. `archilyzer tracker` is a self-hosted HTTP + WebSocket tracker that tracks only those torrents. `docker-compose.seeder.yml` (profile `seeder`) runs both inside a WireGuard container's network namespace (gluetun, its firewall always on), so a tunnel that is down means no network, never the home connection; the WireGuard config is yours (`SEEDER_WG_CONF`, required, mounted read-only). `archilyzer doctor` compares the seeder's egress address with the host's and fails when they are the same; it says "seeder not configured" until `seeder.sites` names a site. - **Saved videos can be made browser-playable, with a torrent each.** `pnpm ops prepare-playable` (`POST /api/ops/prepare-playable`) and `archilyzer media playable <slug>` remux each of a channel's saved containers — without re-encoding (`-c copy`) — into an mp4 with its index in front, or a webm when it already is one (VP9/AV1 with Opus), drop subtitles, metadata and chapters, and make one single-file torrent of the copy: named `<id>.<ext>`, no web seed, no comment, no "created by", 256 KiB–1 MiB pieces. They go to `playable/<slug>/<id>/` beside the saved-video store, listed in `playable/<slug>/playable.json` with each infohash. `"trackers"` is the announce list written into each torrent (none by default); it is not part of the infohash, so the same torrent can be announced elsewhere later. A video already prepared from the same source (by sha256) is skipped, so a re-run is a no-op; a codec a browser cannot play without re-encoding (HEVC, MPEG-4 Part 2) is listed and left alone. - **A channel's videos can get their media from a local archive.** `pnpm ops attach-media` (`POST /api/ops/attach-media`) and `archilyzer media attach <slug> <source>` take `{"slug", "source"}` — an absolute path to a directory, a `.zip` (read in place: a stored entry is copied straight out of it, with no temp dir) or a `.7z` — and put each held video's file into the saved-video store as its source container, so clip windows and report clips can be cut from it with nothing fetched. The id is the folder's trailing `(<id>)`, else the file's yt-dlp suffix; `"items": [{"id", "path"}]` names exact files and `"match"` narrows the folders. The pointer records where the file came from: `origin: {kind: "local-archive", archive, entry, sha256, attachedAt}`. A video that already has a saved container is left alone unless `"replace": true`; `"createRecords": true` writes a record for a video the channel does not hold (from the folder's yt-dlp `.info.json`, else its `description.txt` and name); `[LOST]` folders are listed and never attached. `"dryRun": true` lists what each folder is, and the held videos the archive has no media for, and writes nothing. The archive is never written to. - **A curated tag can exist on some sites only.** A tag's new **Sites** field on /tags (`sites` in `transcripts/tags.json`; `pnpm ops tags` takes it in a define) names the sites it exists on. Its rules then fire, and its pins apply, only to videos on those sites' channels, and every other site drops it from its records, its counts and its `/tags.json` — where **Hidden** only hid the chip. Empty is every site, as before. Setting it, or changing the channels of those sites, re-derives the corpus's tags once at the next index update. The Eva tags are what this is for: they belong on Anilyzer alone. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.0 version: 3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + bittorrent-tracker: + specifier: ^11.2.3 + version: 11.2.3(supports-color@10.2.2) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -98,6 +101,9 @@ importers: tw-animate-css: specifier: ^1.4.0 version: 1.4.0 + webtorrent: + specifier: ^3.0.21 + version: 3.0.21(bare-url@2.5.4)(supports-color@10.2.2) zod: specifier: ^4.3.6 version: 4.3.6 @@ -125,7 +131,7 @@ importers: version: 5.9.3 wrangler: specifier: 4.147.0 - version: 4.147.0(@types/node@20.19.39) + version: 4.147.0(@types/node@20.19.39)(bufferutil@4.1.0)(utf-8-validate@6.0.6) editor: dependencies: @@ -2171,6 +2177,10 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@silentbot1/nat-api@0.4.9': + resolution: {integrity: sha512-Bm2Fr0sJyGr4B/XgKjQxjGe7Rzs/OlK91OIHsghObxhP3Y4j2y8o7Xjlledu/pxzFEIWaTbZIBSl8ABqoP/WhQ==} + engines: {node: '>=10.0.0'} + '@sindresorhus/is@7.2.0': resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} engines: {node: '>=18'} @@ -2326,6 +2336,12 @@ packages: '@tanstack/virtual-core@3.14.0': resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} + '@thaunknown/simple-peer@10.1.2': + resolution: {integrity: sha512-pcG35e815X43Crr/HVmOWlbsx9Jz3KD27HOhy/BwIjQkDlafzJFl0NKb8B+bf+bsnfQL/BB6Z44cMZPQaYHLYQ==} + + '@thaunknown/simple-websocket@9.1.5': + resolution: {integrity: sha512-FTrycAOMeJIHxHxH4iiq0EHMlmITvim04FGpwRcrytykg4jCFPaRYJqjo8wvBVx88TYfxrtQCaHhzaKLDKS71w==} + '@thaunknown/thirty-two@1.0.6': resolution: {integrity: sha512-Uz2kFb0cNeHg7hds8BtI/umcwzaSMUdbkeQgZrIZQbp3gWRFjrvrvBHNEB6SQYChVxC3rWHovKx1Ya/vSRRMYA==} engines: {node: '>=0.2.6'} @@ -2548,6 +2564,9 @@ packages: cpu: [x64] os: [win32] + '@webtorrent/http-node@1.3.0': + resolution: {integrity: sha512-GWZQKroPES4z91Ijx6zsOsb7+USOxjy66s8AoTWg0HiBBdfnbtf9aeh3Uav0MgYn4BL8Q7tVSUpd0gGpngKGEQ==} + '@zeit/schemas@2.36.0': resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==} @@ -2565,6 +2584,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + addr-to-ip-port@2.0.0: + resolution: {integrity: sha512-9bYbtjamtdLHZSqVIUXhilOryNPiL+x+Q5J/Unpg4VY3ZIkK3fT52UoErj1NdUeVm3J1t2iBEAur4Ywbl/bahw==} + engines: {node: '>=12.20.0'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -2658,6 +2681,14 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.9.0: + resolution: {integrity: sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2665,6 +2696,62 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-addon-resolve@1.11.0: + resolution: {integrity: sha512-bTUBSqBjzQQzo2/iIk6KxAClZfahy38EPsJV2bPJ+p2+kS8h+hT5TE5QggpkFUrCnLL4HDTA4DDM+YiNdyol0w==} + peerDependencies: + bare-url: '*' + peerDependenciesMeta: + bare-url: + optional: true + + bare-events@2.9.2: + resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.8.3: + resolution: {integrity: sha512-KQi9ShyPS/nOCtKpma7At8wbq4ORSNUmD67hsZrPcaWJcEmL8PHyS2xVWT308y8/LvHFOUTytTTDsD8dInx6bA==} + engines: {bare: '>=1.28.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-module-resolve@1.12.6: + resolution: {integrity: sha512-tsoOqHmtF6VmNX4LYDBZwvQVkSmEagnbykNjK31LMxz/amyKfjCs4K976n7uJd1bvWfxbpLQ3Fy7qq8an6jR6Q==} + peerDependencies: + bare-url: '*' + peerDependenciesMeta: + bare-url: + optional: true + + bare-path@3.1.2: + resolution: {integrity: sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==} + + bare-semver@1.1.0: + resolution: {integrity: sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==} + + bare-stream@2.13.4: + resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.5.4: + resolution: {integrity: sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2673,6 +2760,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bencode@2.0.3: + resolution: {integrity: sha512-D/vrAD4dLVX23NalHwb8dSvsUsxeRPO8Y7ToKA015JQYq69MLDOMkC0uGZYA/MPpltLO8rt8eqFC2j8DxjTZ/w==} + bencode@4.0.1: resolution: {integrity: sha512-/bCR3FvgfB8AZk4LlowShU+nfaTNRM2vruJ9aejDa5LpgkCoRjFyEauFiZFxbpxVxQbBFapYCN42Er2dkE/Qgw==} engines: {node: '>=12.20.0'} @@ -2681,6 +2771,34 @@ packages: resolution: {integrity: sha512-VWN7j7sqUssWD6ROZe2m709YL3u5A1IAx1qvAbH3DAhszHt4WPvCzJLRJ+mzmZn1qWIb4mpflwdn95qezz3n8g==} engines: {node: '>=12.20.0'} + bitfield@5.0.1: + resolution: {integrity: sha512-81mvFHCa0L1HsDdKVCTkPKZZ6KFdyXgfQNoneFT7Qk5/PZeBWdPkTFa4KttbR8uHszfjiqOJbeahLOPfCqw4cw==} + engines: {node: '>=20.19.0'} + + bittorrent-dht@11.0.12: + resolution: {integrity: sha512-bFmQq874HMIBon/6KNMf/U1OG+UxJ0UzI9oPra2IRlZLuPA7VwBiA7uEpWaO2ww8G76OFjxB1p1XCOS94FuCPQ==} + engines: {node: '>=12.20.0'} + + bittorrent-lsd@2.0.3: + resolution: {integrity: sha512-FE4ImhpJZo8ahzDS/Qk1Ol+3Ea1sIhCPWE2WSjiv1s8YoCSEf7JBL3bbCJW5hdVNK5ohtZ3vH5xGjZ9QWFiatQ==} + engines: {node: '>=12.20.0'} + + bittorrent-peerid@2.0.2: + resolution: {integrity: sha512-V2iUvbtv31uLA8XFxbhB3f/fKTxWGaa6FI/3wSm93YAS/DS8IkeRWCyYGQ13Xbc9ajR40nsxNjGtVt7OHB3VOQ==} + engines: {node: '>=12.20.0'} + + bittorrent-protocol@5.0.9: + resolution: {integrity: sha512-GIRHD1W9tFIkLbJP6OYZs6sWFJbfxaRnx4FYX1hvig/9+Ui691ZvJcQ+y6oSUmV7fh7/ehKT1lICh1/UEYsAcw==} + engines: {node: '>=18'} + + bittorrent-tracker@11.2.3: + resolution: {integrity: sha512-i9pWOf1YAQWyTjDnpWwcTfEYDi5XwqD6f48pKmIHByb7x2XU86CqK+IsyMy1buKkDZ3rDKht2jIeKCZf1Dmd8g==} + engines: {node: '>=16.0.0'} + hasBin: true + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} @@ -2713,6 +2831,10 @@ packages: buffer@5.6.0: resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==} + bufferutil@4.1.0: + resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} + engines: {node: '>=6.14.2'} + bytes@3.0.0: resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} engines: {node: '>= 0.8'} @@ -2721,6 +2843,9 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cache-chunk-store@3.2.2: + resolution: {integrity: sha512-2lJdWbgHFFxcSth9s2wpId3CR3v1YC63KjP4T9WhpW7LWlY7Hiiei3QwwqzkWqlJTfR8lSy9F5kRQECeyj+yQA==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2756,6 +2881,21 @@ packages: resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chrome-dgram@3.0.6: + resolution: {integrity: sha512-bqBsUuaOiXiqxXt/zA/jukNJJ4oaOtc7ciwqJpZVEaaXwwxqgI2/ZdG02vXYWUhHGziDlvGMQWk0qObgJwVYKA==} + + chrome-dns@1.0.1: + resolution: {integrity: sha512-HqsYJgIc8ljJJOqOzLphjAs79EUuWSX3nzZi2LNkzlw3GIzAeZbaSektC8iT/tKvLqZq8yl1GJu5o6doA4TRbg==} + + chrome-net@3.3.4: + resolution: {integrity: sha512-Jzy2EnzmE+ligqIZUsmWnck9RBXLuUy6CaKyuNMtowFG3ZvLt8d+WBJCTPEludV0DHpIKjAOlwjFmTaEdfdWCw==} + + chunk-store-iterator@1.0.4: + resolution: {integrity: sha512-LGjzJNmk7W1mrdaBoJNztPumT2ACmgjHmI1AMm8aeGYOl4+LKaYC/yfnx27i++LiAtoe/dR+3jC8HRzb6gW4/A==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -2787,6 +2927,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + compact2string@1.4.1: + resolution: {integrity: sha512-3D+EY5nsRhqnOwDxveBv5T8wGo4DEvYxjDtPGmdOX+gfr5gE92c2RC0w2wa+xEefm07QuVqqcF3nZJUZ92l/og==} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -2809,6 +2952,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cpus@1.0.3: + resolution: {integrity: sha512-PXHBvGLuL69u55IkLa5e5838fLhIMHxmkV4ge42a8alGyn7BtawYgI0hQ849EedvtHIOLNNH3i6eQU1BiE9SUA==} + create-torrent@6.1.3: resolution: {integrity: sha512-/XARuf6/TmOLICFkUU7pf4KPL/vlIFyk2xOJjQ4np8Xzl8xLt9W7KJps1EVnZyUCMHbAm5xYfCoDrgVw3F4FxA==} engines: {node: '>=12'} @@ -2915,6 +3061,10 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -2926,6 +3076,10 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + default-gateway@7.2.2: + resolution: {integrity: sha512-AD7TrdNNPXRZIGw63dw+lnGmT4v7ggZC5NHNJgAYWm5njrwoze1q5JSAW9YuLy2tjnoLUG/r8FEB93MCh9QJPg==} + engines: {node: '>= 16'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -2964,10 +3118,16 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.21.0: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} + err-code@3.0.1: + resolution: {integrity: sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -3017,6 +3177,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -3153,6 +3316,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -3169,10 +3335,18 @@ packages: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} + execa@7.2.0: + resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} + engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} + execa@9.6.1: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3180,6 +3354,9 @@ packages: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -3220,6 +3397,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filename-reserved-regex@3.0.0: + resolution: {integrity: sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -3246,10 +3427,26 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + freelist@1.0.3: + resolution: {integrity: sha512-Ji7fEnMdZDGbS5oXElpRJsn9jPvBR8h/037D3bzreNmS8809cISq/2D9//JbA/TaZmkkN8cmecXwmQHmM+NHhg==} + + fs-chunk-store@5.0.1: + resolution: {integrity: sha512-bhD1YfAzdOugxu4VepYjxvzZwik9Qunn69Ogj47DZmogD6h/VkD/4vArNRoQ+kPYYOFqex3c9Ob2iAOkNYdkgw==} + engines: {node: '>=12.20.0'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@11.3.4: resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} + fs-native-extensions@1.5.1: + resolution: {integrity: sha512-abjiHKkYdcH5M9ikBEJb0MKb/fEpPtZx/yfLHzTptvUAoiFayX0tIe0BTLBU4SAoRyjZLzA0dP1Rn2p0+QRyVg==} + + fsa-chunk-store@1.3.0: + resolution: {integrity: sha512-0WCfuxqqSB6Tz/g7Ar/nwAxMoigXaIXuvfrnLIEFYIA9uc6w9eNaHuBGzU1X3lyM4cpLKCOTUmKAA/gCiTvzMQ==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3309,6 +3506,9 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3372,10 +3572,17 @@ packages: hls.js@1.6.16: resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + http-parser-js@0.4.13: + resolution: {integrity: sha512-u8u5ZaG0Tr/VvHlucK2ufMuOp4/5bvwgneXle+y228K5rMbJOlVjThONcaAw3ikAy8b2OO9RfEucdMHFz3UWMA==} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + human-signals@4.3.1: + resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} + engines: {node: '>=14.18.0'} + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} @@ -3391,6 +3598,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate-chunk-store@2.2.0: + resolution: {integrity: sha512-1bHBna0hCa6arRXicu91IiL9RvvkbNYLVq+mzWdaLGZC3hXvX4doh8e1dLhMKez5siu63CYgO5NrGJbRX5lbPA==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3413,6 +3623,25 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.7.3: + resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} + engines: {node: '>= 12'} + + ip-set@3.0.0: + resolution: {integrity: sha512-EkKzllMFYKMHOoeNxM8v10IJM2n9K6L3ruaQx4A/5RrkOInV8cjp9IwkVU0/cpzwwkq1zGNudrAdCjSR97Rf5w==} + engines: {node: '>=18'} + + ip@2.0.1: + resolution: {integrity: sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==} + + ipaddr.js@2.0.1: + resolution: {integrity: sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==} + engines: {node: '>= 10'} + + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -3516,6 +3745,10 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} @@ -3616,6 +3849,15 @@ packages: resolution: {integrity: sha512-Qush0uP+G8ZScpGMZvHUiRfI0YBWuB3gVBYlI0v0vvOJt5FLicco+IkP0a50LqTTQhmts/m6tP5SWE+USyIvcQ==} engines: {node: '>=12.20'} + k-bucket@5.1.0: + resolution: {integrity: sha512-Fac7iINEovXIWU20GPnOMLUbjctiS+cnmyjC4zAUgvs3XPf1vo9akfCHkigftSic/jiKqKl+KA3a/vFcJbHyCg==} + + k-rpc-socket@1.11.1: + resolution: {integrity: sha512-8xtA8oqbZ6v1Niryp2/g4GxW16EQh5MvrUylQoOG+zcrDff5CKttON2XUXvMwlIHq4/2zfPVFiinAccJ+WhxoA==} + + k-rpc@5.1.0: + resolution: {integrity: sha512-FGc+n70Hcjoa/X2JTwP+jMIOpBz+pkRffHnSl9yrYiwUxg3FIgD50+u1ePfJUOnRCnx6pbjmVk5aAeB1wIijuQ==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3630,6 +3872,9 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + last-one-wins@1.0.4: + resolution: {integrity: sha512-t+KLJFkHPQk8lfN6WBOiGkiUXoub+gnb2XTYI2P3aiISL+94xgZ1vgz1SXN/N4hthuOoLXarXfBZPUruyjQtfA==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3708,10 +3953,17 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + lmdb@3.5.4: resolution: {integrity: sha512-9FKQA6G1MMtqNxfxvSBNXD/axeG2QRjYbNh0/ykRL5xYcRbCm2vXq7B9bhc7nSuKdHzr8/BHIwfPuYYH1UsXXw==} hasBin: true + load-ip-set@3.0.2: + resolution: {integrity: sha512-UD1GM3CLlkC3b0gAKIxd+6SFJb1WQttWyYhwvjdWjGpJKzu32HnaSMfWtUtVgRtFY+K5vgrvecuVQLRxx5Ojag==} + engines: {node: '>=12.20.0'} + load-script@1.0.0: resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==} @@ -3732,6 +3984,14 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru@3.1.0: + resolution: {integrity: sha512-5OUtoiVIGU4VXBOshidmtOsvBIvcQR6FD/RzWSvaeHyxCGB+PCUCu+52lqMfdc0h/2CLvHhZS4TwUmMQrrMbBQ==} + engines: {node: '>= 0.4.0'} + + lt_donthave@2.0.7: + resolution: {integrity: sha512-FdfetmgzUr0IC2ZXHaOhAMEsvw8BFo7kDM7JzfYsJ5pO5+xzjfZIPdLSQWE4+/UTlQA7D8IU5ZJ0LTuLE4BRBQ==} + engines: {node: '>=12.20.0'} + lucide-react@1.16.0: resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==} peerDependencies: @@ -3760,6 +4020,9 @@ packages: memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + memory-chunk-store@1.3.5: + resolution: {integrity: sha512-E1Xc1U4ifk/FkC2ZsWhCaW1xg9HbE/OBmQTLe2Tr9c27YPSLbW7kw1cnb3kQWD1rDtErFJHa7mB9EVrs7aTx9g==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -3783,10 +4046,23 @@ packages: resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} engines: {node: '>= 0.6'} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + miniflare@5.20261001.0-alpha: resolution: {integrity: sha512-GaimS5mSIOMyvd16ga+e1/QkI8cmp3z35QPHFex0DktqNQf2RVZQwMPQXdyoUreUiFP/0Gpe2MoQInTyMAD5xA==} engines: {node: '>=22.0.0'} @@ -3801,6 +4077,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -3819,6 +4098,12 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + napi-macros@2.2.2: + resolution: {integrity: sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -3831,6 +4116,10 @@ packages: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} + engines: {node: '>= 0.4.0'} + next@16.2.3: resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==} engines: {node: '>=20.9.0'} @@ -3852,9 +4141,17 @@ packages: sass: optional: true + node-abi@3.98.0: + resolution: {integrity: sha512-Llh8noNw7s15m+rmIrZnZQbe4gLyasL9loT74rC92PkmwWKeEedKvc7o6pjp8B3MhrYT/Cl846NOYZbz1wmddw==} + engines: {node: '>=10'} + node-addon-api@6.1.0: resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + node-datachannel@0.32.3: + resolution: {integrity: sha512-Aok1ZhLsll472lRefgWYuWJ0070jh0ecHravTdRyZEmoESumebMEQV8Y+poBwSW2ZbEwAokAOGsK5Cu8pDDT2g==} + engines: {node: '>=18.20.0'} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -3872,6 +4169,10 @@ packages: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} @@ -3879,6 +4180,10 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} @@ -3926,6 +4231,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -4029,6 +4338,12 @@ packages: resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -4040,6 +4355,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4047,6 +4365,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + queue-tick@1.0.1: + resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} + radix-ui@1.6.0: resolution: {integrity: sha512-EUEC70O03EgxWMP5aoqfBZ6iLC5bczFagGy7zhSYRt8o5DP7IWNiP3ywetse3L9b8843ExB0OGWZvgbYVJuNeg==} peerDependencies: @@ -4060,10 +4381,26 @@ packages: '@types/react-dom': optional: true + random-access-file@4.1.2: + resolution: {integrity: sha512-GQM6R78DceZDcQod8KxlDFwXIiUvlvuy1EOzxTDsjuDjW5NlnlZi0MOk6iI4itAj/2vcvdqcEExYbVpC/dJcEw==} + + random-access-storage@3.0.2: + resolution: {integrity: sha512-Es9maUyWdJXWKckKy9s1+vT+DEgAt+PBb9lxPaake/0EDUsHehloKGv9v1zimS2V3gpFAcQXubvc1Rgci2sDPQ==} + + random-iterate@1.0.1: + resolution: {integrity: sha512-Jdsdnezu913Ot8qgKgSgs63XkAjEsnMcS1z+cC6D6TNXsUXsMxy0RpclF2pzGZTEiTXL9BiArdGTEexcv4nqcA==} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + range-parser@1.2.0: resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} engines: {node: '>= 0.6'} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -4148,6 +4485,9 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + record-cache@1.2.0: + resolution: {integrity: sha512-kyy3HWCez2WrotaL3O4fTn0rsIdfRKOdQQcEJ9KpvmKmbffKVvwsloX063EgRUlpJIXHiDQFhJcTbZequ2uTZw==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4163,6 +4503,10 @@ packages: resolution: {integrity: sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==} engines: {node: '>=0.10.0'} + require-addon@1.3.0: + resolution: {integrity: sha512-j8pifaPNZI7ShKY3ihXZwF0qaEjua8zmlqPvDil0a8SBnyRvjCbAiEQuzO0UcoXH1sqU5U/ilXSjYd1W5NYwAA==} + engines: {bare: '>=1.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -4183,9 +4527,15 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + run-parallel-limit@1.1.0: + resolution: {integrity: sha512-jJA7irRNM91jaKc3Hcl1npHsFLOXOoTkPCUL1JEa1R82O2miplXXRaGdjW/KM/98YQWDhJLiSs793CnXfblJUw==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + run-series@1.1.9: + resolution: {integrity: sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==} + safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -4201,6 +4551,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4282,6 +4636,20 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks@2.8.10: + resolution: {integrity: sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -4292,6 +4660,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + speed-limiter@1.0.2: + resolution: {integrity: sha512-Ax+TbUOho84bWUc3AKqWtkIvAIVws7d6QI4oJkgH4yQ5Yil+lR3vjd/7qd51dHKGzS5bFxg0++QwyNRN7s6rZA==} + + split@1.0.1: + resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -4302,6 +4676,12 @@ packages: stream-browserify@3.0.0: resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -4333,6 +4713,10 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string2compact@2.0.2: + resolution: {integrity: sha512-ZUUIyrS+sSj84gR6fFoz/boiqCptyw37/hMxIehReuf6ekpJOlwSXhTfEasqc5t3QucBXi2ghwLlHOSC8hjEaw==} + engines: {node: '>=12.20.0'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -4352,6 +4736,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + strip-final-newline@4.0.0: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} @@ -4399,6 +4787,31 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + throughput@1.0.2: + resolution: {integrity: sha512-jvK1ZXuhsggjb3qYQjMiU/AVYYiTeqT5thWvYR2yuy2LGM84P5MSSyAinwHahGsdBYKR9m9HncVR/3f3nFKkxg==} + + thunky@1.1.0: + resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + + timeout-refresh@1.0.3: + resolution: {integrity: sha512-Mz0CX4vBGM5lj8ttbIFt7o4ZMxk/9rgudJRh76EvB7xXZMur7T/cjRiH2w4Fmkq0zxf2QpM8IFvOSRn8FEu3gA==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4410,6 +4823,14 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + torrent-discovery@11.0.21: + resolution: {integrity: sha512-x+RGu60qJp2u4S7B7XglyYCrfDJWKB0RIexODlA5dpBpMh94q4MbmRjusdbvxN+XD4NHhpwEfub/CRA9pUXjEQ==} + engines: {node: '>=16.0.0'} + + torrent-piece@4.0.1: + resolution: {integrity: sha512-bL5lLm0CknuXMJ3c8Xw+27etPTH7P1vLXwEASQ3bktZlWV2ZEkCW1l0ssV6Y9EHpcUlf0aqB9x49bkgMA4JrHw==} + engines: {node: '>=12.20.0'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -4427,6 +4848,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -4491,6 +4915,12 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unordered-array-remove@1.0.2: + resolution: {integrity: sha512-45YsfD6svkgaCBNyvD+dFHm4qFX9g3wRSIVgWVPtm2OCnphvPxzJoe20ATsiNpNJrmzHifnxm+BN5F7gFT/4gw==} + + unordered-set@2.0.1: + resolution: {integrity: sha512-eUmNTPzdx+q/WvOHW0bgGYLWvWHNT3PTKEQLg0MAQhc0AHASHVHoP/9YytYd4RBVariqno/mEUhVZN98CmD7bg==} + unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} @@ -4526,9 +4956,26 @@ packages: '@types/react': optional: true + ut_metadata@5.0.1: + resolution: {integrity: sha512-V+6jKXXvkH8YQDFeSpvjSkn7Yiz7Juz3LhIkA1QhxuvOjhrqwlZKJfRMOjVSEW/mPa9Bpk4hvFOCim98tzR04w==} + engines: {node: '>=22'} + + ut_pex@5.0.2: + resolution: {integrity: sha512-tPWnEmFIpn47IgZ3CyMzA4onr6zGtBrR+DVtRGoyCpC59OgqELvj9jkzLuisCwIH5FdUQiwqtXtsiAWwpUfrKw==} + engines: {node: '>=12.20.0'} + + utf-8-validate@6.0.6: + resolution: {integrity: sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==} + engines: {node: '>=6.14.2'} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utp-native@2.5.3: + resolution: {integrity: sha512-sWTrWYXPhhWJh+cS2baPzhaZc89zwlWCfwSthUjGhLkZztyPhcQllo+XVVCbNGi7dhyRlxkWxN4NKU6FbA9Y8w==} + engines: {node: '>=8.12'} + hasBin: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -4543,6 +4990,14 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webrtc-polyfill@1.2.2: + resolution: {integrity: sha512-t/H2OySa7lZSq2f5j4whmr+IWCl5TqF4daGotE8VhPP7U+E4x5x8MK1wqOkqlb4xxRlag4kBB9enU4oxNk//+g==} + engines: {node: '>=16.0.0'} + + webtorrent@3.0.21: + resolution: {integrity: sha512-PFgLphma0dsUWmbWrZ016Cja+j+3D3DXuhk09A5u9qVAzH1T4Vj1VZd8j+zb8sDwPv7NA7+qgPtDEN4iFd6wdw==} + engines: {node: '>=22'} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4555,6 +5010,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-runtime@1.4.0: + resolution: {integrity: sha512-0ugbP4CJW4e2D20jvEcC4973dCgIaHI4Rw1PT+26U9zEve7FyYdWAIwUnoeOYvoCfn+wXHoHTKb1KhkYlb60Pw==} + which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} @@ -4606,6 +5064,26 @@ packages: utf-8-validate: optional: true + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -6299,6 +6777,17 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@silentbot1/nat-api@0.4.9(supports-color@10.2.2)': + dependencies: + chrome-dgram: 3.0.6 + cross-fetch-ponyfill: 1.0.3 + debug: 4.4.3(supports-color@10.2.2) + default-gateway: 7.2.2 + unordered-array-remove: 1.0.2 + xml2js: 0.6.2 + transitivePeerDependencies: + - supports-color + '@sindresorhus/is@7.2.0': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -6433,6 +6922,32 @@ snapshots: '@tanstack/virtual-core@3.14.0': {} + '@thaunknown/simple-peer@10.1.2(supports-color@10.2.2)': + dependencies: + debug: 4.4.3(supports-color@10.2.2) + err-code: 3.0.1 + streamx: 2.28.1 + uint8-util: 2.3.2 + webrtc-polyfill: 1.2.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + '@thaunknown/simple-websocket@9.1.5(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@6.0.6)': + dependencies: + debug: 4.4.3(supports-color@10.2.2) + queue-microtask: 1.2.3 + streamx: 2.28.1 + uint8-util: 2.3.2 + ws: 8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bare-abort-controller + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + '@thaunknown/thirty-two@1.0.6': dependencies: uint8-util: 2.3.2 @@ -6643,6 +7158,11 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@webtorrent/http-node@1.3.0': + dependencies: + freelist: 1.0.3 + http-parser-js: 0.4.13 + '@zeit/schemas@2.36.0': {} abort-controller@3.0.0: @@ -6655,6 +7175,8 @@ snapshots: acorn@8.16.0: {} + addr-to-ip-port@2.0.0: {} + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -6774,31 +7296,158 @@ snapshots: axobject-query@4.1.0: {} + b4a@1.9.0: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} + bare-addon-resolve@1.11.0(bare-url@2.5.4): + dependencies: + bare-module-resolve: 1.12.6(bare-url@2.5.4) + bare-semver: 1.1.0 + optionalDependencies: + bare-url: 2.5.4 + optional: true + + bare-events@2.9.2: {} + + bare-fs@4.8.3: + dependencies: + bare-events: 2.9.2 + bare-path: 3.1.2 + bare-stream: 2.13.4(bare-events@2.9.2) + bare-url: 2.5.4 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-module-resolve@1.12.6(bare-url@2.5.4): + dependencies: + bare-semver: 1.1.0 + optionalDependencies: + bare-url: 2.5.4 + optional: true + + bare-path@3.1.2: {} + + bare-semver@1.1.0: + optional: true + + bare-stream@2.13.4(bare-events@2.9.2): + dependencies: + b4a: 1.9.0 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.5.4: + dependencies: + bare-path: 3.1.2 + base64-js@1.5.1: {} baseline-browser-mapping@2.10.23: {} + bencode@2.0.3: {} + bencode@4.0.1: dependencies: uint8-util: 2.3.2 bep53-range@2.0.1: {} - blake3-wasm@2.1.5: {} + bitfield@5.0.1: {} - block-iterator@1.1.2: {} + bittorrent-dht@11.0.12(supports-color@10.2.2): + dependencies: + bencode: 4.0.1 + debug: 4.4.3(supports-color@10.2.2) + k-bucket: 5.1.0 + k-rpc: 5.1.0 + last-one-wins: 1.0.4 + lru: 3.1.0 + randombytes: 2.1.0 + record-cache: 1.2.0 + transitivePeerDependencies: + - react-native-b4a + - supports-color - bowser@2.14.1: {} + bittorrent-lsd@2.0.3(supports-color@10.2.2): + dependencies: + chrome-dgram: 3.0.6 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color - boxen@7.0.0: + bittorrent-peerid@2.0.2: {} + + bittorrent-protocol@5.0.9(supports-color@10.2.2): dependencies: - ansi-align: 3.0.1 - camelcase: 7.0.1 - chalk: 5.0.1 + bencode: 4.0.1 + bitfield: 5.0.1 + debug: 4.4.3(supports-color@10.2.2) + streamx: 2.28.0 + throughput: 1.0.2 + uint8-util: 2.3.2 + unordered-array-remove: 1.0.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + bittorrent-tracker@11.2.3(supports-color@10.2.2): + dependencies: + '@thaunknown/simple-peer': 10.1.2(supports-color@10.2.2) + '@thaunknown/simple-websocket': 9.1.5(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@6.0.6) + bencode: 4.0.1 + bittorrent-peerid: 2.0.2 + chrome-dgram: 3.0.6 + compact2string: 1.4.1 + cross-fetch-ponyfill: 1.0.3 + debug: 4.4.3(supports-color@10.2.2) + ip: 2.0.1 + lru: 3.1.0 + minimist: 1.2.8 + once: 1.4.0 + queue-microtask: 1.2.3 + random-iterate: 1.0.1 + run-parallel: 1.2.0 + run-series: 1.1.9 + socks: 2.8.10 + string2compact: 2.0.2 + uint8-util: 2.3.2 + unordered-array-remove: 1.0.2 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + bl@4.1.0: + dependencies: + buffer: 5.6.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + blake3-wasm@2.1.5: {} + + block-iterator@1.1.2: {} + + bowser@2.14.1: {} + + boxen@7.0.0: + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.0.1 cli-boxes: 3.0.0 string-width: 5.1.2 type-fest: 2.19.0 @@ -6831,10 +7480,20 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bufferutil@4.1.0: + dependencies: + node-gyp-build: 4.8.4 + optional: true + bytes@3.0.0: {} bytes@3.1.2: {} + cache-chunk-store@3.2.2: + dependencies: + lru: 3.1.0 + queue-microtask: 1.2.3 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -6869,6 +7528,25 @@ snapshots: chalk@5.0.1: {} + chownr@1.1.4: {} + + chrome-dgram@3.0.6: + dependencies: + inherits: 2.0.4 + run-series: 1.1.9 + + chrome-dns@1.0.1: + dependencies: + chrome-net: 3.3.4 + + chrome-net@3.3.4: + dependencies: + inherits: 2.0.4 + + chunk-store-iterator@1.0.4: + dependencies: + block-iterator: 1.1.2 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -6903,6 +7581,10 @@ snapshots: color-name@1.1.4: {} + compact2string@1.4.1: + dependencies: + ipaddr.js: 2.5.0 + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -6927,6 +7609,8 @@ snapshots: cookie@1.1.1: {} + cpus@1.0.3: {} + create-torrent@6.1.3: dependencies: bencode: 4.0.1 @@ -7035,12 +7719,20 @@ snapshots: decimal.js-light@2.5.1: {} + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + deep-extend@0.6.0: {} deep-is@0.1.4: {} deepmerge@4.3.1: {} + default-gateway@7.2.2: + dependencies: + execa: 7.2.0 + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -7080,11 +7772,17 @@ snapshots: emoji-regex@9.2.2: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.21.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 + err-code@3.0.1: {} + error-stack-parser-es@1.0.5: {} es-abstract@1.24.2: @@ -7248,6 +7946,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -7461,6 +8161,12 @@ snapshots: eventemitter3@4.0.7: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + events@3.3.0: {} eventsource-parser@3.1.0: {} @@ -7481,6 +8187,18 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + execa@7.2.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 4.3.1 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 3.0.7 + strip-final-newline: 3.0.0 + execa@9.6.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -7496,10 +8214,14 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expand-template@2.0.3: {} + fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7537,6 +8259,8 @@ snapshots: dependencies: flat-cache: 4.0.1 + filename-reserved-regex@3.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -7563,12 +8287,42 @@ snapshots: dependencies: fetch-blob: 3.2.0 + freelist@1.0.3: {} + + fs-chunk-store@5.0.1(bare-url@2.5.4): + dependencies: + filename-reserved-regex: 3.0.0 + queue-microtask: 1.2.3 + random-access-file: 4.1.2(bare-url@2.5.4) + run-parallel: 1.2.0 + thunky: 1.1.0 + uint8-util: 2.3.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bare-url + - react-native-b4a + + fs-constants@1.0.0: {} + fs-extra@11.3.4: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 universalify: 2.0.1 + fs-native-extensions@1.5.1(bare-url@2.5.4): + dependencies: + require-addon: 1.3.0(bare-url@2.5.4) + which-runtime: 1.4.0 + transitivePeerDependencies: + - bare-url + optional: true + + fsa-chunk-store@1.3.0: + dependencies: + filename-reserved-regex: 3.0.0 + fsevents@2.3.2: optional: true @@ -7631,6 +8385,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -7682,8 +8438,12 @@ snapshots: hls.js@1.6.16: {} + http-parser-js@0.4.13: {} + human-signals@2.1.0: {} + human-signals@4.3.1: {} + human-signals@8.0.1: {} ieee754@1.2.1: {} @@ -7692,6 +8452,10 @@ snapshots: ignore@7.0.5: {} + immediate-chunk-store@2.2.0: + dependencies: + queue-microtask: 1.2.3 + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -7711,6 +8475,18 @@ snapshots: internmap@2.0.3: {} + ip-address@10.7.3: {} + + ip-set@3.0.0: + dependencies: + ip-address: 10.7.3 + + ip@2.0.1: {} + + ipaddr.js@2.0.1: {} + + ipaddr.js@2.5.0: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -7809,6 +8585,8 @@ snapshots: is-stream@2.0.1: {} + is-stream@3.0.0: {} + is-stream@4.0.1: {} is-string@1.1.1: @@ -7899,6 +8677,23 @@ snapshots: junk@4.0.1: {} + k-bucket@5.1.0: + dependencies: + randombytes: 2.1.0 + + k-rpc-socket@1.11.1: + dependencies: + bencode: 2.0.3 + chrome-dgram: 3.0.6 + chrome-dns: 1.0.1 + chrome-net: 3.3.4 + + k-rpc@5.1.0: + dependencies: + k-bucket: 5.1.0 + k-rpc-socket: 1.11.1 + randombytes: 2.1.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7911,6 +8706,8 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + last-one-wins@1.0.4: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -7965,6 +8762,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + limiter@1.1.5: {} + lmdb@3.5.4: dependencies: '@harperfast/extended-iterable': 1.0.3 @@ -7982,6 +8781,15 @@ snapshots: '@lmdb/lmdb-win32-arm64': 3.5.4 '@lmdb/lmdb-win32-x64': 3.5.4 + load-ip-set@3.0.2: + dependencies: + cross-fetch-ponyfill: 1.0.3 + ip-set: 3.0.0 + netmask: 2.1.1 + once: 1.4.0 + queue-microtask: 1.2.3 + split: 1.0.1 + load-script@1.0.0: {} locate-path@6.0.0: @@ -8000,6 +8808,17 @@ snapshots: dependencies: yallist: 3.1.1 + lru@3.1.0: + dependencies: + inherits: 2.0.4 + + lt_donthave@2.0.7(supports-color@10.2.2): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + unordered-array-remove: 1.0.2 + transitivePeerDependencies: + - supports-color + lucide-react@1.16.0(react@19.2.4): dependencies: react: 19.2.4 @@ -8022,6 +8841,10 @@ snapshots: memoize-one@5.2.1: {} + memory-chunk-store@1.3.5: + dependencies: + queue-microtask: 1.2.3 + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -8039,15 +8862,21 @@ snapshots: dependencies: mime-db: 1.33.0 + mime@3.0.0: {} + mimic-fn@2.1.0: {} - miniflare@5.20261001.0-alpha(@types/node@20.19.39): + mimic-fn@4.0.0: {} + + mimic-response@3.1.0: {} + + miniflare@5.20261001.0-alpha(@types/node@20.19.39)(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.35.4(@types/node@20.19.39) undici: 7.29.1 workerd: 1.20261001.1 - ws: 8.21.0 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) youch: 4.1.0-beta.10 transitivePeerDependencies: - '@types/node' @@ -8064,6 +8893,8 @@ snapshots: minimist@1.2.8: {} + mkdirp-classic@0.5.3: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -8086,12 +8917,19 @@ snapshots: nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + + napi-macros@2.2.2: + optional: true + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} negotiator@0.6.4: {} + netmask@2.1.1: {} + next@16.2.3(@babel/core@7.29.0(supports-color@10.2.2))(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.3 @@ -8117,8 +8955,16 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-abi@3.98.0: + dependencies: + semver: 7.8.5 + node-addon-api@6.1.0: {} + node-datachannel@0.32.3: + dependencies: + prebuild-install: 7.1.3 + node-domexception@1.0.0: {} node-exports-info@1.6.0: @@ -8138,12 +8984,19 @@ snapshots: dependencies: detect-libc: 2.1.2 + node-gyp-build@4.8.4: + optional: true + node-releases@2.0.38: {} npm-run-path@4.0.1: dependencies: path-key: 3.1.1 + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + npm-run-path@6.0.0: dependencies: path-key: 4.0.0 @@ -8201,6 +9054,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8293,6 +9150,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.98.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} pretty-ms@9.3.0: @@ -8305,10 +9177,17 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} queue-microtask@1.2.3: {} + queue-tick@1.0.1: {} + radix-ui@1.6.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@radix-ui/primitive': 1.1.4 @@ -8372,8 +9251,36 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + random-access-file@4.1.2(bare-url@2.5.4): + dependencies: + bare-fs: 4.8.3 + bare-path: 3.1.2 + random-access-storage: 3.0.2 + optionalDependencies: + fs-native-extensions: 1.5.1(bare-url@2.5.4) + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bare-url + - react-native-b4a + + random-access-storage@3.0.2: + dependencies: + bare-events: 2.9.2 + queue-tick: 1.0.1 + transitivePeerDependencies: + - bare-abort-controller + + random-iterate@1.0.1: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + range-parser@1.2.0: {} + range-parser@1.3.0: {} + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -8470,6 +9377,12 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + record-cache@1.2.0: + dependencies: + b4a: 1.9.0 + transitivePeerDependencies: + - react-native-b4a + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -8499,6 +9412,13 @@ snapshots: dependencies: rc: 1.2.8 + require-addon@1.3.0(bare-url@2.5.4): + dependencies: + bare-addon-resolve: 1.11.0(bare-url@2.5.4) + transitivePeerDependencies: + - bare-url + optional: true + require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -8516,10 +9436,16 @@ snapshots: reusify@1.1.0: {} + run-parallel-limit@1.1.0: + dependencies: + queue-microtask: 1.2.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + run-series@1.1.9: {} + safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -8541,6 +9467,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + sax@1.6.1: {} + scheduler@0.27.0: {} semver@6.3.1: {} @@ -8700,6 +9628,21 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + smart-buffer@4.2.0: {} + + socks@2.8.10: + dependencies: + ip-address: 10.7.3 + smart-buffer: 4.2.0 + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -8707,6 +9650,18 @@ snapshots: source-map-js@1.2.1: {} + speed-limiter@1.0.2: + dependencies: + limiter: 1.1.5 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + split@1.0.1: + dependencies: + through: 2.3.8 + stable-hash@0.0.5: {} stop-iteration-iterator@1.1.0: @@ -8719,6 +9674,24 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -8781,6 +9754,11 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string2compact@2.0.2: + dependencies: + addr-to-ip-port: 2.0.0 + ipaddr.js: 2.0.1 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -8797,6 +9775,8 @@ snapshots: strip-final-newline@2.0.0: {} + strip-final-newline@3.0.0: {} + strip-final-newline@4.0.0: {} strip-json-comments@2.0.1: {} @@ -8824,6 +9804,43 @@ snapshots: tapable@2.3.3: {} + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.9.0 + transitivePeerDependencies: + - react-native-b4a + + through@2.3.8: {} + + throughput@1.0.2: {} + + thunky@1.1.0: {} + + timeout-refresh@1.0.3: + optional: true + tiny-invariant@1.3.3: {} tinyglobby@0.2.16: @@ -8835,6 +9852,22 @@ snapshots: dependencies: is-number: 7.0.0 + torrent-discovery@11.0.21(supports-color@10.2.2): + dependencies: + bittorrent-dht: 11.0.12(supports-color@10.2.2) + bittorrent-lsd: 2.0.3(supports-color@10.2.2) + bittorrent-tracker: 11.2.3(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) + run-parallel: 1.2.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - supports-color + + torrent-piece@4.0.1: + dependencies: + uint8-util: 2.3.2 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -8855,6 +9888,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + tw-animate-css@1.4.0: {} type-check@0.4.0: @@ -8930,6 +9967,11 @@ snapshots: universalify@2.0.1: {} + unordered-array-remove@1.0.2: {} + + unordered-set@2.0.1: + optional: true + unrs-resolver@1.11.1: dependencies: napi-postinstall: 0.3.4 @@ -8984,8 +10026,37 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + ut_metadata@5.0.1(supports-color@10.2.2): + dependencies: + bencode: 4.0.1 + bitfield: 5.0.1 + debug: 4.4.3(supports-color@10.2.2) + uint8-util: 2.3.2 + transitivePeerDependencies: + - supports-color + + ut_pex@5.0.2: + dependencies: + bencode: 4.0.1 + compact2string: 1.4.1 + string2compact: 2.0.2 + + utf-8-validate@6.0.6: + dependencies: + node-gyp-build: 4.8.4 + optional: true + util-deprecate@1.0.2: {} + utp-native@2.5.3: + dependencies: + napi-macros: 2.2.2 + node-gyp-build: 4.8.4 + readable-stream: 3.6.2 + timeout-refresh: 1.0.3 + unordered-set: 2.0.1 + optional: true + vary@1.1.2: {} victory-vendor@36.9.2: @@ -9009,6 +10080,60 @@ snapshots: web-streams-polyfill@3.3.3: {} + webrtc-polyfill@1.2.2: + dependencies: + node-datachannel: 0.32.3 + + webtorrent@3.0.21(bare-url@2.5.4)(supports-color@10.2.2): + dependencies: + '@silentbot1/nat-api': 0.4.9(supports-color@10.2.2) + '@thaunknown/simple-peer': 10.1.2(supports-color@10.2.2) + '@webtorrent/http-node': 1.3.0 + addr-to-ip-port: 2.0.0 + bitfield: 5.0.1 + bittorrent-dht: 11.0.12(supports-color@10.2.2) + bittorrent-protocol: 5.0.9(supports-color@10.2.2) + cache-chunk-store: 3.2.2 + chunk-store-iterator: 1.0.4 + cpus: 1.0.3 + create-torrent: 6.1.3 + cross-fetch-ponyfill: 1.0.3 + debug: 4.4.3(supports-color@10.2.2) + escape-html: 1.0.3 + fs-chunk-store: 5.0.1(bare-url@2.5.4) + fsa-chunk-store: 1.3.0 + immediate-chunk-store: 2.2.0 + join-async-iterator: 1.1.1 + load-ip-set: 3.0.2 + lt_donthave: 2.0.7(supports-color@10.2.2) + memory-chunk-store: 1.3.5 + mime: 3.0.0 + once: 1.4.0 + parse-torrent: 11.0.24 + pump: 3.0.4 + queue-microtask: 1.2.3 + random-iterate: 1.0.1 + range-parser: 1.3.0 + run-parallel: 1.2.0 + run-parallel-limit: 1.1.0 + speed-limiter: 1.0.2 + streamx: 2.28.0 + throughput: 1.0.2 + torrent-discovery: 11.0.21(supports-color@10.2.2) + torrent-piece: 4.0.1 + uint8-util: 2.3.2 + unordered-array-remove: 1.0.2 + ut_metadata: 5.0.1(supports-color@10.2.2) + ut_pex: 5.0.2 + optionalDependencies: + utp-native: 2.5.3 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bare-url + - react-native-b4a + - supports-color + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -9040,6 +10165,9 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-runtime@1.4.0: + optional: true + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 @@ -9068,13 +10196,13 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20261001.1 '@cloudflare/workerd-windows-64': 1.20261001.1 - wrangler@4.147.0(@types/node@20.19.39): + wrangler@4.147.0(@types/node@20.19.39)(bufferutil@4.1.0)(utf-8-validate@6.0.6): dependencies: '@cloudflare/kv-asset-handler': 0.5.0 '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261001.1) blake3-wasm: 2.1.5 esbuild: 0.28.1 - miniflare: 5.20261001.0-alpha(@types/node@20.19.39) + miniflare: 5.20261001.0-alpha(@types/node@20.19.39)(bufferutil@4.1.0)(utf-8-validate@6.0.6) path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 workerd: 1.20261001.1 @@ -9093,7 +10221,22 @@ snapshots: wrappy@1.0.2: {} - ws@8.21.0: {} + ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + + ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + + xml2js@0.6.2: + dependencies: + sax: 1.6.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} yallist@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml @@ -8,11 +8,21 @@ packages: - umtool allowBuilds: + # The seeder's WebTorrent (release 21 D4b): node-datachannel is WebRTC in + # node (a prebuilt binary, fetched by prebuild-install). The rest have pure-JS + # fallbacks (ws's bufferutil/utf-8-validate) or are optional (utp-native: uTP; + # without it the seeder speaks TCP and WebRTC), and ip-set's script is only a + # package-manager check. + bufferutil: false esbuild: true + ip-set: false lmdb: true msgpackr-extract: true + node-datachannel: true sharp: true unrs-resolver: true + utf-8-validate: false + utp-native: false workerd: false ignoredBuiltDependencies: diff --git a/settings.json.example b/settings.json.example @@ -207,5 +207,14 @@ "previewBranch": "preview", "hub": "off", "homepage": "off" + }, + "seeder": { + "sites": [], + "trackers": [], + "maxUploadKiBps": 0, + "maxConnections": 50, + "pollSeconds": 60, + "standbyAfterSeconds": 600, + "bindInterface": "" } }