@torrent-tv/proxy 2.9.23 → 2.9.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ # observability — delta spec
2
+
3
+ ## ADDED Requirements
4
+
5
+ ### Requirement: Health endpoints report the version
6
+ `GET /healthz` and `GET /health` SHALL include the running proxy version
7
+ (from package.json) alongside the existing `ok` field.
8
+
9
+ #### Scenario: Version visible remotely
10
+ - **WHEN** a client requests `/healthz`
11
+ - **THEN** the response contains `ok: true` and the exact npm package version
12
+ of the running proxy
13
+
14
+ ### Requirement: Peer-discovery diagnostics
15
+ The proxy SHALL log, per torrent: an added line with file count, `private`
16
+ flag and tracker count; every torrent-level warning (tracker rejections and
17
+ errors surface as warnings); and each tracker announce response with the
18
+ seeder/leecher counts returned. Logging failures MUST NOT affect playback
19
+ (best-effort, defensive against WebTorrent internals).
20
+
21
+ #### Scenario: Zero-peer torrent is explainable
22
+ - **WHEN** a torrent sits at zero peers
23
+ - **THEN** the log shows either the tracker's rejection/warning text or an
24
+ announce response with zero seeders — distinguishing "tracker refused us"
25
+ from "the swarm is empty"
26
+
27
+ ### Requirement: No SSDP listener warnings
28
+ Port mapping SHALL NOT flood the log with `MaxListenersExceededWarning`
29
+ regardless of how many ports one mapper maps or renews.
30
+
31
+ #### Scenario: WebRTC UDP range mapping
32
+ - **WHEN** the UDP mapper maps its 10-port range and later auto-renews it
33
+ - **THEN** no MaxListenersExceededWarning lines appear in the log
@@ -0,0 +1,20 @@
1
+ # Tasks: Proxy observability
2
+
3
+ ## 1. Implementation
4
+
5
+ - [x] 1.1 Version in `/healthz` and `/health` (createRequire package.json in
6
+ server.js, passed as route dep)
7
+ - [x] 1.2 torrent-pool.js: added-torrent line (files/private/trackers),
8
+ torrent `warning` logging, tracker `update` (announce seeders/leechers)
9
+ logging with defensive access, client-level warning logging
10
+ - [x] 1.3 port-mapper.js: setMaxListeners(0) on the UPnP SSDP emitter after
11
+ the first successful map()
12
+ - [x] 1.4 Syntax checks + healthz handler smoke test (version present)
13
+
14
+ ## 2. Release
15
+
16
+ - [x] 2.1 CHANGELOG.md entry at 2.9.25
17
+ - [ ] 2.2 `npm run patch` (needs npm auth), then ha-addon bump 0.2.47 + push
18
+ - [ ] 2.3 After the addon updates: verify version via `/healthz`, watch the
19
+ addon log for announce lines on a real torrent, confirm no SSDP
20
+ warnings
@@ -0,0 +1,38 @@
1
+ schema: spec-driven
2
+
3
+ context: |
4
+ @torrent-tv/proxy — WebTorrent + ffmpeg, published to npm. Downloads a torrent
5
+ and streams the chosen file to the browser over a WebRTC data channel (plain
6
+ HTTPS transport planned), transcoding to HLS only for the track(s) the browser
7
+ cannot play natively (supported track = copied).
8
+ Constraints (see CLAUDE.md for the full set):
9
+ - Deployment-agnostic: the HA addon is only one way to run it. No
10
+ Home-Assistant assumptions; runtime detection with graceful fallback only.
11
+ - Routes: routes/<path>/<method>.js exporting handle<Name><Method>.
12
+ - All code, comments and docs in English.
13
+ - Every behavioural change adds a CHANGELOG.md entry at current package.json
14
+ version + 1 patch. Never edit package.json version (npm run patch bumps it).
15
+ - Any behavioural proxy change requires bumping ha-addon config.yaml version
16
+ in the same changeset (the addon must be re-released to pull the new proxy).
17
+ - HLS is VOD-only with server-side seeking (never -hls_playlist_type event).
18
+ - Hardware encoders are gated by a strict startup test; software libx264 is
19
+ the fallback.
20
+
21
+ # Project context (optional)
22
+ # This is shown to AI when creating artifacts.
23
+ # Add your tech stack, conventions, style guides, domain knowledge, etc.
24
+ # Example:
25
+ # context: |
26
+ # Tech stack: TypeScript, React, Node.js
27
+ # We use conventional commits
28
+ # Domain: e-commerce platform
29
+
30
+ # Per-artifact rules (optional)
31
+ # Add custom rules for specific artifacts.
32
+ # Example:
33
+ # rules:
34
+ # proposal:
35
+ # - Keep proposals under 500 words
36
+ # - Always include a "Non-goals" section
37
+ # tasks:
38
+ # - Break tasks into chunks of max 2 hours
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.23",
3
+ "version": "2.9.25",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -5,8 +5,9 @@
5
5
  *
6
6
  * @param {import("fastify").FastifyRequest} _req
7
7
  * @param {import("fastify").FastifyReply} reply
8
+ * @param {{ version: string }} deps
8
9
  * @returns {Promise<void>}
9
10
  */
10
- export async function handleHealthGet(_req, reply) {
11
- return reply.send({ ok: true });
11
+ export async function handleHealthGet(_req, reply, { version } = {}) {
12
+ return reply.send({ ok: true, version });
12
13
  }
@@ -5,8 +5,9 @@
5
5
  *
6
6
  * @param {import("fastify").FastifyRequest} _req
7
7
  * @param {import("fastify").FastifyReply} reply
8
+ * @param {{ version: string }} deps
8
9
  * @returns {Promise<void>}
9
10
  */
10
- export async function handleHealthzGet(_req, reply) {
11
- return reply.send({ ok: true });
11
+ export async function handleHealthzGet(_req, reply, { version } = {}) {
12
+ return reply.send({ ok: true, version });
12
13
  }
package/server.js CHANGED
@@ -12,6 +12,7 @@ import fastifyHelmet from "@fastify/helmet";
12
12
  import fastifyStatic from "@fastify/static";
13
13
  import getPort from "get-port";
14
14
  import path from "node:path";
15
+ import { createRequire } from "node:module";
15
16
  import { fileURLToPath } from "node:url";
16
17
  import { handleHealthGet } from "./routes/health/get.js";
17
18
  import { handleHealthzGet } from "./routes/healthz/get.js";
@@ -32,6 +33,8 @@ import { logger } from "./utils/logger.js";
32
33
 
33
34
  const __filename = fileURLToPath(import.meta.url);
34
35
  const __dirname = path.dirname(__filename);
36
+ const require = createRequire(import.meta.url);
37
+ const { version } = require("./package.json");
35
38
  const publicRoot = path.resolve(__dirname, "./public");
36
39
 
37
40
  /**
@@ -121,8 +124,8 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }
121
124
  torrentPool
122
125
  });
123
126
 
124
- app.get("/health", async (req, reply) => handleHealthGet(req, reply));
125
- app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply));
127
+ app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
128
+ app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
126
129
  app.post("/api/sources", async (req, reply) =>
127
130
  handleApiSourcesPost(req, reply, { sourceRegistry })
128
131
  );
@@ -157,6 +157,13 @@ export function createPortMapper({
157
157
  `map ${p}`
158
158
  );
159
159
  mappedCount++;
160
+ if (mappedCount === 1) {
161
+ // The UPnP SSDP emitter gains one listener per map()/renewal; a
162
+ // 10-port range exceeds Node's default limit of 10 and floods the
163
+ // log with MaxListenersExceededWarning. The client is created
164
+ // lazily by the first map(), so lift the limit right after it.
165
+ instance._upnpClient?.ssdp?.setMaxListeners?.(0);
166
+ }
160
167
  } catch (error) {
161
168
  logger.warn(`port-mapper: failed to map ${protocol} ${p}: ${describeError(error)}`);
162
169
  }
@@ -7,9 +7,23 @@
7
7
  */
8
8
 
9
9
  import crypto from "node:crypto";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { rmSync } from "node:fs";
10
13
  import WebTorrent from "webtorrent";
11
14
  import { logger } from "../utils/logger.js";
12
15
 
16
+ // WebTorrent's default download root (see webtorrent lib/torrent.js: TMP =
17
+ // path.join(os.tmpdir(), 'webtorrent')). We use the default store, so all
18
+ // torrent data lives under here.
19
+ const WEBTORRENT_STORE_ROOT = path.join(os.tmpdir(), "webtorrent");
20
+
21
+ // How long a torrent may sit with zero active file readers before it is
22
+ // removed (with its on-disk store). Generous so brief gaps between ffmpeg
23
+ // range reads — or a short pause — do not evict an in-use torrent; a longer
24
+ // idle (viewer gone) frees the disk. Re-requesting re-adds (re-downloads) it.
25
+ const TORRENT_IDLE_TTL_MS = 300_000;
26
+
13
27
  // Bytes ahead of a read position to mark CRITICAL (download-first) on each
14
28
  // range request. Big enough to unstick a seek into an undownloaded region,
15
29
  // small enough not to make "everything critical" (which defeats prioritization).
@@ -55,7 +69,26 @@ export class TorrentPool {
55
69
  */
56
70
  #pending = new Map();
57
71
 
72
+ /**
73
+ * Pending idle-removal timers, keyed by torrent object. A torrent with zero
74
+ * file refcount is scheduled for removal; re-acquiring it cancels the timer.
75
+ *
76
+ * @type {Map<import("webtorrent").Torrent, ReturnType<typeof setTimeout>>}
77
+ */
78
+ #idleTimers = new Map();
79
+
58
80
  constructor() {
81
+ // Sweep orphaned torrent data left by a previous hard kill (no graceful
82
+ // shutdown ran, so destroyAll never cleaned the store). Safe here: no
83
+ // torrents are loaded yet at construction. Best-effort, synchronous so it
84
+ // completes before the client starts writing.
85
+ try {
86
+ rmSync(WEBTORRENT_STORE_ROOT, { recursive: true, force: true });
87
+ } catch (error) {
88
+ const message = error instanceof Error ? error.message : String(error);
89
+ logger.warn(`torrent-pool: could not sweep orphaned store at startup: ${message}`);
90
+ }
91
+
59
92
  /** @type {import("webtorrent").WebTorrent} */
60
93
  this.client = new WebTorrent();
61
94
 
@@ -77,6 +110,50 @@ export class TorrentPool {
77
110
  this.client.on("error", (error) => {
78
111
  logger.error(`WebTorrent client error: ${error.message}`);
79
112
  });
113
+ this.client.on("warning", (warning) => {
114
+ const message = warning instanceof Error ? warning.message : String(warning);
115
+ logger.warn(`torrent-pool: client warning: ${message}`);
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Attach peer-discovery diagnostics to a freshly added torrent: tracker
121
+ * announce results (seeders/leechers per announce) and torrent-level
122
+ * warnings (tracker rejections/errors surface here). Without these a
123
+ * zero-peer torrent gives no clue WHY it has no peers.
124
+ *
125
+ * @param {string} label - Short source label for log lines.
126
+ * @param {import("webtorrent").Torrent} torrent
127
+ * @returns {void}
128
+ */
129
+ #attachSwarmDiagnostics(label, torrent) {
130
+ const trackerCount = Array.isArray(torrent.announce) ? torrent.announce.length : 0;
131
+ logger.info(
132
+ `torrent-pool: [${label}] added: files=${torrent.files?.length ?? 0} ` +
133
+ `private=${torrent.private ? "yes" : "no"} trackers=${trackerCount}`
134
+ );
135
+
136
+ torrent.on("warning", (warning) => {
137
+ const message = warning instanceof Error ? warning.message : String(warning);
138
+ logger.warn(`torrent-pool: [${label}] warning: ${message}`);
139
+ });
140
+
141
+ // bittorrent-tracker's Client emits "update" with each announce response.
142
+ // `complete`/`incomplete` are the tracker's seeder/leecher counts — the
143
+ // authoritative answer to "does the tracker accept us and does the swarm
144
+ // have anyone in it". Internal API, so strictly best-effort.
145
+ const tracker = torrent.discovery?.tracker;
146
+ if (tracker && typeof tracker.on === "function") {
147
+ tracker.on("update", (data) => {
148
+ const announceUrl = typeof data?.announce === "string" ? data.announce : "?";
149
+ logger.info(
150
+ `torrent-pool: [${label}] announce ${announceUrl}: ` +
151
+ `seeders=${data?.complete ?? "?"} leechers=${data?.incomplete ?? "?"}`
152
+ );
153
+ });
154
+ } else {
155
+ logger.info(`torrent-pool: [${label}] tracker client not exposed; announce results not logged`);
156
+ }
80
157
  }
81
158
 
82
159
  /**
@@ -115,6 +192,9 @@ export class TorrentPool {
115
192
  this.client.off("error", onError);
116
193
  this.torrents.set(key, readyTorrent);
117
194
  this.#pending.delete(key);
195
+ // Key layout is `${sourceType}:${sha1}`; log with the sha1 prefix so
196
+ // lines correlate with the [stats] source key.
197
+ this.#attachSwarmDiagnostics(key.split(":")[1]?.slice(0, 8) ?? key, readyTorrent);
118
198
  resolve(readyTorrent);
119
199
  });
120
200
  });
@@ -170,6 +250,8 @@ export class TorrentPool {
170
250
  usage = new Map();
171
251
  this.fileUsageByTorrent.set(torrent, usage);
172
252
  }
253
+ // The torrent is in use again — cancel any pending idle removal.
254
+ this.#cancelIdleRemoval(torrent);
173
255
  usage.set(fileIndex, (usage.get(fileIndex) ?? 0) + 1);
174
256
  this.#syncSelections(torrent, usage);
175
257
 
@@ -187,11 +269,84 @@ export class TorrentPool {
187
269
  }
188
270
  if (usage.size === 0) {
189
271
  this.fileUsageByTorrent.delete(torrent);
272
+ // No active readers — schedule removal (with store) after an idle TTL.
273
+ this.#scheduleIdleRemoval(torrent);
190
274
  }
191
275
  this.#syncSelections(torrent, usage);
192
276
  };
193
277
  }
194
278
 
279
+ /**
280
+ * Schedule removal of a torrent (with its on-disk store) after
281
+ * {@link TORRENT_IDLE_TTL_MS} of zero file refcount. Idempotent — replaces
282
+ * any existing timer for the torrent.
283
+ *
284
+ * @param {import("webtorrent").Torrent} torrent
285
+ * @returns {void}
286
+ */
287
+ #scheduleIdleRemoval(torrent) {
288
+ if (!torrent) {
289
+ return;
290
+ }
291
+ this.#cancelIdleRemoval(torrent);
292
+ const timer = setTimeout(() => {
293
+ this.#idleTimers.delete(torrent);
294
+ // Re-check: a new acquire since scheduling would have cancelled this
295
+ // timer, but guard anyway against a race.
296
+ const usage = this.fileUsageByTorrent.get(torrent);
297
+ if (usage && usage.size > 0) {
298
+ return;
299
+ }
300
+ this.#removeTorrent(torrent);
301
+ }, TORRENT_IDLE_TTL_MS);
302
+ timer.unref?.();
303
+ this.#idleTimers.set(torrent, timer);
304
+ }
305
+
306
+ /**
307
+ * Cancel a pending idle-removal timer for a torrent, if any.
308
+ *
309
+ * @param {import("webtorrent").Torrent} torrent
310
+ * @returns {void}
311
+ */
312
+ #cancelIdleRemoval(torrent) {
313
+ const timer = this.#idleTimers.get(torrent);
314
+ if (timer) {
315
+ clearTimeout(timer);
316
+ this.#idleTimers.delete(torrent);
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Remove a torrent from the pool together with its on-disk store, freeing
322
+ * disk while the proxy keeps running. Best-effort.
323
+ *
324
+ * @param {import("webtorrent").Torrent} torrent
325
+ * @returns {void}
326
+ */
327
+ #removeTorrent(torrent) {
328
+ if (!torrent) {
329
+ return;
330
+ }
331
+ // Drop it from the source→torrent map so a later request re-adds it.
332
+ for (const [key, value] of this.torrents) {
333
+ if (value === torrent) {
334
+ this.torrents.delete(key);
335
+ break;
336
+ }
337
+ }
338
+ this.fileUsageByTorrent.delete(torrent);
339
+ const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
340
+ try {
341
+ torrent.destroy({ destroyStore: true }, () => {
342
+ logger.info(`torrent-pool: removed idle torrent "${name}" and its store`);
343
+ });
344
+ } catch (error) {
345
+ const message = error instanceof Error ? error.message : String(error);
346
+ logger.warn(`torrent-pool: failed to remove idle torrent "${name}": ${message}`);
347
+ }
348
+ }
349
+
195
350
  /**
196
351
  * Return download statistics for a torrent and optionally a specific file.
197
352
  *
@@ -438,6 +593,12 @@ export class TorrentPool {
438
593
  * @returns {Promise<void>}
439
594
  */
440
595
  async destroyAll() {
596
+ // Cancel any pending idle-removal timers — destroyAll handles teardown.
597
+ for (const timer of this.#idleTimers.values()) {
598
+ clearTimeout(timer);
599
+ }
600
+ this.#idleTimers.clear();
601
+
441
602
  if (!this.client || this.client.destroyed) {
442
603
  return;
443
604
  }