@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.
- package/.claude/commands/opsx/apply.md +155 -0
- package/.claude/commands/opsx/archive.md +160 -0
- package/.claude/commands/opsx/explore.md +174 -0
- package/.claude/commands/opsx/propose.md +109 -0
- package/.claude/commands/opsx/sync.md +143 -0
- package/.claude/skills/openspec-apply-change/SKILL.md +159 -0
- package/.claude/skills/openspec-archive-change/SKILL.md +117 -0
- package/.claude/skills/openspec-explore/SKILL.md +289 -0
- package/.claude/skills/openspec-propose/SKILL.md +113 -0
- package/.claude/skills/openspec-sync-specs/SKILL.md +147 -0
- package/CHANGELOG.md +4 -1
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -0
- package/openspec/changes/proxy-observability/design.md +38 -0
- package/openspec/changes/proxy-observability/proposal.md +49 -0
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -0
- package/openspec/changes/proxy-observability/tasks.md +20 -0
- package/openspec/config.yaml +38 -0
- package/package.json +1 -1
- package/routes/health/get.js +3 -2
- package/routes/healthz/get.js +3 -2
- package/server.js +5 -2
- package/services/port-mapper.js +7 -0
- package/services/torrent-pool.js +161 -0
|
@@ -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
package/routes/health/get.js
CHANGED
|
@@ -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
|
}
|
package/routes/healthz/get.js
CHANGED
|
@@ -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
|
);
|
package/services/port-mapper.js
CHANGED
|
@@ -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
|
}
|
package/services/torrent-pool.js
CHANGED
|
@@ -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
|
}
|