@torrent-tv/proxy 2.9.24 → 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,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.24",
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
  }
@@ -110,6 +110,50 @@ export class TorrentPool {
110
110
  this.client.on("error", (error) => {
111
111
  logger.error(`WebTorrent client error: ${error.message}`);
112
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
+ }
113
157
  }
114
158
 
115
159
  /**
@@ -148,6 +192,9 @@ export class TorrentPool {
148
192
  this.client.off("error", onError);
149
193
  this.torrents.set(key, readyTorrent);
150
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);
151
198
  resolve(readyTorrent);
152
199
  });
153
200
  });