import { EventEmitter } from "node:events"; import type { Config } from "@config/Config"; import type { ClientProfile } from "../client/ClientProfile.js"; import type { TorrentFileInfo } from "../torrent/TorrentFile.js"; import { HttpTracker } from "../tracker/HttpTracker.js"; import type { ITracker } from "../tracker/ITracker.js"; import type { TrackerResponse } from "../tracker/TrackerResponse.js"; import { UdpTracker } from "../tracker/UdpTracker.js"; import { SpeedSimulator } from "./SpeedSimulator.js"; export type SeederStatus = "idle" | "running" | "paused" | "stopping" | "stopped" | "error"; export interface SeederState { infoHashHex: string; name: string; trackerUrl: string; uploaded: number; downloaded: number; left: number; status: SeederStatus; lastAnnounce: Date | null; lastInterval: number; seeders: number; leechers: number; peers: number; error: string | null; } export interface AnnounceRecord { timestamp: Date; trackerUrl: string; uploaded: number; seeders?: number; leechers?: number; peers: number; event: string; } export class FakeSeeder extends EventEmitter { // Typed on() overloads — avoids unsafe declaration merging on(event: "announce", listener: (record: AnnounceRecord) => void): this; on(event: "stopped", listener: () => void): this; on(event: "stateChange", listener: (state: SeederState) => void): this; on(event: string, listener: (...args: unknown[]) => void): this { return super.on(event, listener); } private readonly torrent: TorrentFileInfo; private readonly profile: ClientProfile; private readonly config: Config; private readonly trackers: ITracker[]; private readonly speedSim: SpeedSimulator; private status: SeederStatus = "idle"; private uploaded = 0; private readonly downloaded = 0; private left: number; private lastAnnounce: Date | null = null; private lastInterval = 1800; private lastSeeder = 0; private lastLeecher = 0; private lastPeers = 0; private error: string | null = null; private announceTimer: ReturnType | null = null; private tickTimer: ReturnType | null = null; private lastTickTime = Date.now(); constructor(torrent: TorrentFileInfo, profile: ClientProfile, config: Config) { super(); this.torrent = torrent; this.profile = profile; this.config = config; this.left = 0; // We're a seeder — we have the full file this.speedSim = new SpeedSimulator({ minUploadRateBytesPerSec: config.minUploadRateBytesPerSec, maxUploadRateBytesPerSec: config.maxUploadRateBytesPerSec, }); this.trackers = buildTrackers(torrent.announceList); } get infoHashHex(): string { return this.torrent.infoHashHex; } getState(): SeederState { return { infoHashHex: this.torrent.infoHashHex, name: this.torrent.name, trackerUrl: this.torrent.announce, uploaded: this.uploaded, downloaded: this.downloaded, left: this.left, status: this.status, lastAnnounce: this.lastAnnounce, lastInterval: this.lastInterval, seeders: this.lastSeeder, leechers: this.lastLeecher, peers: this.lastPeers, error: this.error, }; } async start(): Promise { if (this.status === "running") return; this.setStatus("running"); // Start speed tick this.lastTickTime = Date.now(); this.tickTimer = setInterval(() => { const now = Date.now(); const delta = now - this.lastTickTime; this.lastTickTime = now; this.uploaded += this.speedSim.tick(delta); this.emit("stateChange", this.getState()); }, 5_000); // First announce: started await this.doAnnounce("started"); this.scheduleNextAnnounce(); } async pause(): Promise { if (this.status !== "running") return; if (this.announceTimer) { clearTimeout(this.announceTimer); this.announceTimer = null; } if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; } // Notify trackers we're leaving the swarm (best-effort) await Promise.allSettled( this.trackers.map((t) => t.stop({ infoHash: this.torrent.infoHash, peerId: this.profile.peerId, port: this.config.announcePort, uploaded: this.uploaded, downloaded: this.downloaded, left: this.left, userAgent: this.profile.userAgent, key: this.profile.key, }) ) ); this.setStatus("paused"); } async resume(): Promise { if (this.status !== "paused") return; this.setStatus("running"); // Restart speed tick this.lastTickTime = Date.now(); this.tickTimer = setInterval(() => { const now = Date.now(); const delta = now - this.lastTickTime; this.lastTickTime = now; this.uploaded += this.speedSim.tick(delta); this.emit("stateChange", this.getState()); }, 5_000); // Re-announce as started then resume loop await this.doAnnounce("started"); this.scheduleNextAnnounce(); } async stop(): Promise { if (this.status === "stopped" || this.status === "stopping") return; const waspaused = this.status === "paused"; this.setStatus("stopping"); if (this.announceTimer) { clearTimeout(this.announceTimer); this.announceTimer = null; } if (this.tickTimer) { clearInterval(this.tickTimer); this.tickTimer = null; } // If already paused, trackers were already notified — skip the stopped announce if (!waspaused) { await Promise.allSettled( this.trackers.map((t) => t.stop({ infoHash: this.torrent.infoHash, peerId: this.profile.peerId, port: this.config.announcePort, uploaded: this.uploaded, downloaded: this.downloaded, left: this.left, userAgent: this.profile.userAgent, key: this.profile.key, }) ) ); } this.setStatus("stopped"); this.emit("stopped"); } private scheduleNextAnnounce(): void { const intervalMs = this.lastInterval * 1000; this.announceTimer = setTimeout(async () => { await this.doAnnounce(""); if (this.status === "running") { this.scheduleNextAnnounce(); } }, intervalMs); } private async doAnnounce(event: "started" | "stopped" | "completed" | ""): Promise { const params = { infoHash: this.torrent.infoHash, peerId: this.profile.peerId, port: this.config.announcePort, uploaded: this.uploaded, downloaded: this.downloaded, left: this.left, event, userAgent: this.profile.userAgent, compact: true, numWant: 50, key: this.profile.key, }; // Announce to all trackers, collect results const results = await Promise.allSettled(this.trackers.map((t) => t.announce(params))); let bestInterval = this.lastInterval; for (let i = 0; i < results.length; i++) { const result = results[i]; const tracker = this.trackers[i]; if (result.status === "fulfilled") { const res: TrackerResponse = result.value; this.lastAnnounce = new Date(); this.lastSeeder = res.seeders ?? this.lastSeeder; this.lastLeecher = res.leechers ?? this.lastLeecher; this.lastPeers = res.peers.length; bestInterval = Math.min(bestInterval, res.interval); this.error = null; // clear any previous error const record: AnnounceRecord = { timestamp: this.lastAnnounce, trackerUrl: tracker.url, uploaded: this.uploaded, seeders: res.seeders, leechers: res.leechers, peers: res.peers.length, event, }; this.emit("announce", record); } else { this.error = (result.reason as Error).message; // Fallback: scrape to at least get current seeder/leecher counts if (tracker.scrape) { tracker.scrape(this.torrent.infoHash).then((stats) => { if (stats) { this.lastSeeder = stats.seeders; this.lastLeecher = stats.leechers; this.emit("stateChange", this.getState()); } }).catch(() => {}); } } } this.lastInterval = bestInterval; this.emit("stateChange", this.getState()); } private setStatus(s: SeederStatus): void { this.status = s; this.emit("stateChange", this.getState()); } } function buildTrackers(urls: string[]): ITracker[] { return urls .map((url) => { try { if (url.startsWith("http://") || url.startsWith("https://")) { return new HttpTracker(url); } if (url.startsWith("udp://")) { return new UdpTracker(url); } } catch { // skip malformed URLs } return null; }) .filter((t): t is ITracker => t !== null); }