From f75c5b71f0db5982b965f63eee1ff23e046170c5 Mon Sep 17 00:00:00 2001 From: Julien Quiaios Date: Fri, 3 Apr 2026 19:23:30 -0400 Subject: [PATCH] feat: Add play/pause feature --- .gitignore | 3 +- src/api/SeederRegistry.ts | 14 +++++++ src/api/routes/torrents.ts | 16 +++++++ src/core/seeder/FakeSeeder.ts | 63 +++++++++++++++++++++++++--- ui/src/components/ConfigModal.svelte | 11 ++--- ui/src/components/TorrentList.svelte | 61 ++++++++++++++++++++++----- ui/src/lib/api.ts | 10 ++++- 7 files changed, 155 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index 56aac5a..9ea16be 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ dist/ .env *.log -torrents/* \ No newline at end of file +torrents/* +idea/ \ No newline at end of file diff --git a/src/api/SeederRegistry.ts b/src/api/SeederRegistry.ts index 37d5955..eb2a382 100644 --- a/src/api/SeederRegistry.ts +++ b/src/api/SeederRegistry.ts @@ -65,6 +65,20 @@ export class SeederRegistry { return seeder.getState(); } + async pauseTorrent(infoHashHex: string): Promise { + const entry = this.seeders.get(infoHashHex); + if (!entry) return false; + await entry.seeder.pause(); + return true; + } + + async resumeTorrent(infoHashHex: string): Promise { + const entry = this.seeders.get(infoHashHex); + if (!entry) return false; + await entry.seeder.resume(); + return true; + } + async removeTorrent(infoHashHex: string): Promise { const entry = this.seeders.get(infoHashHex); if (!entry) return false; diff --git a/src/api/routes/torrents.ts b/src/api/routes/torrents.ts index 766de03..338626a 100644 --- a/src/api/routes/torrents.ts +++ b/src/api/routes/torrents.ts @@ -46,6 +46,22 @@ export function torrentsRouter(registry: SeederRegistry) { }); }); + /** Pause a torrent — sends 'stopped' to trackers and halts the announce loop */ + app.post("/:hash/pause", async (c) => { + const { hash } = c.req.param(); + const ok = await registry.pauseTorrent(hash); + if (!ok) return res.notFound(c); + return c.json({ ok: true }); + }); + + /** Resume a paused torrent — re-announces as 'started' and restarts the loop */ + app.post("/:hash/resume", async (c) => { + const { hash } = c.req.param(); + const ok = await registry.resumeTorrent(hash); + if (!ok) return res.notFound(c); + return c.json({ ok: true }); + }); + /** Remove a torrent — sends 'stopped' to trackers then removes it */ app.delete("/:hash", async (c) => { const { hash } = c.req.param(); diff --git a/src/core/seeder/FakeSeeder.ts b/src/core/seeder/FakeSeeder.ts index 362c978..7950b2f 100644 --- a/src/core/seeder/FakeSeeder.ts +++ b/src/core/seeder/FakeSeeder.ts @@ -8,7 +8,7 @@ import type { TrackerResponse } from "../tracker/TrackerResponse.js"; import { UdpTracker } from "../tracker/UdpTracker.js"; import { SpeedSimulator } from "./SpeedSimulator.js"; -export type SeederStatus = "idle" | "running" | "stopping" | "stopped" | "error"; +export type SeederStatus = "idle" | "running" | "paused" | "stopping" | "stopped" | "error"; export interface SeederState { infoHashHex: string; @@ -118,9 +118,8 @@ export class FakeSeeder extends EventEmitter { this.scheduleNextAnnounce(); } - async stop(): Promise { - if (this.status === "stopped" || this.status === "stopping") return; - this.setStatus("stopping"); + async pause(): Promise { + if (this.status !== "running") return; if (this.announceTimer) { clearTimeout(this.announceTimer); @@ -131,7 +130,7 @@ export class FakeSeeder extends EventEmitter { this.tickTimer = null; } - // Best-effort stopped announce to all trackers + // Notify trackers we're leaving the swarm (best-effort) await Promise.allSettled( this.trackers.map((t) => t.stop({ @@ -147,6 +146,60 @@ export class FakeSeeder extends EventEmitter { ) ); + 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"); } diff --git a/ui/src/components/ConfigModal.svelte b/ui/src/components/ConfigModal.svelte index 081852c..ff878d3 100644 --- a/ui/src/components/ConfigModal.svelte +++ b/ui/src/components/ConfigModal.svelte @@ -65,9 +65,8 @@ - -