@torrent-tv/proxy 2.64.1 → 2.64.3

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.md CHANGED
@@ -42,6 +42,9 @@ Linux-only host (e.g. POSIX-only signals must degrade elsewhere).
42
42
  `routes/*` are now thin HTTP translators.
43
43
  - `container-index/` — legacy readers (ebml-reader, matroska/mp4/avi keyframe and
44
44
  subtitle tables) — used internally by `container/*`, deprecated as direct import.
45
+ - `docs/logs.md` — where to find logs (HA `docker logs` + `/data/proxy.log`, DO
46
+ `infra-server-1` with forwarded frontend `POST /api/client-logs`). Browser
47
+ console not needed.
45
48
  - `playback-planner.js` — single ffmpeg probe returns audioCodec, videoCodec,
46
49
  container, durationSeconds. `mode` is advisory; the browser decides.
47
50
  - `hls-session-manager.js` — one ffmpeg per (source, file, settings). Serves a
package/docs/logs.md ADDED
@@ -0,0 +1,43 @@
1
+ # Logs — where to look
2
+
3
+ All logs are visible via container `docker logs`, no browser console copy-paste needed.
4
+
5
+ ## HA proxy (aarch64, addon `b34a1737_torrent_tv_proxy`)
6
+
7
+ Image `b34a1737/aarch64-addon-torrent_tv_proxy:<version>` = `@torrent-tv/proxy` `<version>` (see `ha-addon/torrent_tv_proxy/config.yaml`).
8
+
9
+ - Console + file are the same: the proxy logs to both `stdout` and `/data/proxy.log` on start (`logging to /data/proxy.log as well as the console`).
10
+ - Via container:
11
+ ```bash
12
+ ssh ha "sudo docker logs app_b34a1737_torrent_tv_proxy --tail 200"
13
+ ssh ha "sudo docker exec app_b34a1737_torrent_tv_proxy cat /data/proxy.log | tail -n 300"
14
+ ```
15
+ - Filter by session: every transcode session logs its id, e.g. `003ed2fd-7c9b-4cd5-9d05-ff875ff2be23`, `hold segment-00068.mp4 failed`, `encode-run failed`, `EXITED_*`.
16
+ - Host file `/data/proxy.log` survives `docker logs` rotation; `core dumps: 1 present` line shows kept dump under `/data`.
17
+
18
+ SSH to HA requires `MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,umac-128-etm@openssh.com` — server `OpenSSH_10.3` on `homeassistant.local` offers only `*-etm` (`Unable to negotiate` / `Corrupted MAC` otherwise). Already in `~/.ssh/config` `Host ha`.
19
+
20
+ ## DO server (webauth.courses, `infra-server-1`)
21
+
22
+ - Server is `infra-server-1` (`ghcr.io/torrent-tv/server:latest`) on `do` (`206.189.97.152`).
23
+ - Frontend logs are forwarded: browser batches `{sessionId, tag, signalSessionId, lines: [{level,ts,msg}]}` and `POST`s to `https://webauth.courses/api/client-logs` (`server/routes/api/client-logs/post.js:56`), server does `console.log` with prefix `[client <tag> <id> sig=<webrtcSessionId>]`. They appear together with backend logs in the same container.
24
+ - Via container:
25
+ ```bash
26
+ ssh do "docker logs infra-server-1 --tail 200 | cat"
27
+ # filter a single viewing:
28
+ ssh do "docker logs infra-server-1 --tail 500 | grep 003ed2fd"
29
+ ssh do "docker logs infra-server-1 --tail 500 | grep '\[client'"
30
+ ```
31
+ - No need to open eruda or copy browser console on phone — it is already in `infra-server-1` logs.
32
+
33
+ ## Quick triage
34
+
35
+ - Rewind/seek bug (hold 0ms → 500): HA `hold ... failed after 0ms → 500` + `encode-run` lines for the same `<sessionId>`, and DO `[client ...] fragLoadError / levelLoadError 500` for same `sn`.
36
+ - Cushion / link budget: HA `memory: rss=... anon=...` and `cushion` lines; DO `[eta]` / `[cushion]` from client.
37
+ - Update check: `ssh ha "sudo docker exec hassio_cli ha apps info b34a1737_torrent_tv_proxy | grep version"` and `ssh ha "sudo docker ps --filter name=app_b34a1737_torrent_tv_proxy"`.
38
+
39
+ ## Related
40
+
41
+ - `ha-addon/CLAUDE.md` — addon build/update detour via `hassio_cli`, cache-bust via `config.yaml` version.
42
+ - `docs/container-architecture.md` — what container/track classes log and where.
43
+ - `server/routes/api/client-logs/post.js:1` — sanitization (control chars → space, `MAX_LINES 50`, `MAX_MSG_LEN 2000`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.64.1",
3
+ "version": "2.64.3",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -34,8 +34,13 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
34
34
  torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
35
35
  } catch (error) {
36
36
  const message = error instanceof Error ? error.message : String(error);
37
+ logger.warn(`stats: getTorrent failed for ${sourceKey.slice(0, 8)}: ${message} stack=${error instanceof Error ? error.stack?.split("\n")[1]?.trim() ?? "" : ""}`);
37
38
  return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
38
39
  }
40
+ if (!torrent) {
41
+ logger.warn(`stats: torrent missing for ${sourceKey.slice(0, 8)} (getTorrent returned null/undefined)`);
42
+ return reply.code(500).send({ error: "Torrent instance not found" });
43
+ }
39
44
 
40
45
  const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
41
46
  const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
@@ -21,6 +21,8 @@
21
21
 
22
22
  import { readFile, statfs } from "node:fs/promises";
23
23
  import os from "node:os";
24
+ import v8 from "node:v8";
25
+ import path from "node:path";
24
26
 
25
27
  /** How often the reading is taken and written. */
26
28
  export const MEMORY_REPORT_INTERVAL_MS = 60_000;
@@ -234,6 +236,7 @@ export function startMemoryReport({
234
236
  diskPath = "",
235
237
  intervalMs = MEMORY_REPORT_INTERVAL_MS
236
238
  }) {
239
+ let highWaterRss = 0;
237
240
  const tick = async () => {
238
241
  try {
239
242
  let stores = [];
@@ -243,21 +246,37 @@ export function startMemoryReport({
243
246
  // silent-ok: a store list that cannot be read must not stop the reading
244
247
  // that matters, which is the process's own.
245
248
  }
249
+ const processMemory = readProcessMemory();
246
250
  if (scope === "thread") {
247
- log(describeMemory({ scope, label, process: readProcessMemory(), stores }));
251
+ log(describeMemory({ scope, label, process: processMemory, stores }));
248
252
  return;
249
253
  }
250
254
  const { bytes, measured } = await availableMemory();
255
+ const anonymousBytes = await readAnonymousMemory();
256
+ const diskFreeBytes = diskPath ? await readDiskFree(diskPath) : null;
251
257
  log(describeMemory({
252
258
  scope,
253
259
  label,
254
- process: readProcessMemory(),
260
+ process: processMemory,
255
261
  availableBytes: bytes,
256
262
  availableMeasured: measured,
257
- anonymousBytes: await readAnonymousMemory(),
258
- diskFreeBytes: diskPath ? await readDiskFree(diskPath) : null,
263
+ anonymousBytes,
264
+ diskFreeBytes,
259
265
  stores
260
266
  }));
267
+ // High-water and heap snapshot on growth — gives a file to open in Chrome DevTools
268
+ // when the kernel is about to kill the process. One snapshot per high-water.
269
+ if (processMemory.rss > highWaterRss + 100 * 1024 * 1024 && processMemory.rss > 500 * 1024 * 1024) {
270
+ highWaterRss = processMemory.rss;
271
+ try {
272
+ const snapPath = path.join(os.tmpdir(), `heap-${Date.now()}-${processMemory.rss}.heapsnapshot`);
273
+ v8.writeHeapSnapshot(snapPath);
274
+ log(`memory: wrote heap snapshot to ${snapPath} rss=${Math.round(processMemory.rss / (1024 * 1024))}MB anon=${anonymousBytes ? Math.round(anonymousBytes / (1024 * 1024)) : "?"}MB`);
275
+ } catch {}
276
+ }
277
+ if (anonymousBytes !== null && processMemory.rss > 800 * 1024 * 1024) {
278
+ log(`memory: high rss=${Math.round(processMemory.rss / (1024 * 1024))}MB anon=${Math.round(anonymousBytes / (1024 * 1024))}MB heap=${Math.round(processMemory.heapUsed / (1024 * 1024))}MB — watch for OOM`);
279
+ }
261
280
  } catch {
262
281
  // silent-ok: a reading that fails is not worth ending the series over.
263
282
  }
@@ -1191,7 +1191,7 @@ export class TorrentPool {
1191
1191
  `(~${(freed / (1024 * 1024)).toFixed(0)} MB)`
1192
1192
  );
1193
1193
  this.#cancelIdleRemoval(torrent);
1194
- this.#removeTorrent(torrent);
1194
+ this.#removeTorrent(torrent, "disk-cap");
1195
1195
  used -= freed;
1196
1196
  }
1197
1197
  }
@@ -1582,15 +1582,20 @@ export class TorrentPool {
1582
1582
  return;
1583
1583
  }
1584
1584
  this.#cancelIdleRemoval(torrent);
1585
+ const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
1586
+ const ih = String(torrent.infoHash ?? "?").slice(0, 8);
1587
+ logger.info(`torrent-pool: scheduling idle removal for "${name}" [${ih}] in ${TORRENT_IDLE_TTL_MS / 1000}s`);
1585
1588
  const timer = setTimeout(() => {
1586
1589
  this.#idleTimers.delete(torrent);
1587
1590
  // Re-check: a new acquire since scheduling would have cancelled this
1588
1591
  // timer, but guard anyway against a race.
1589
1592
  const usage = this.fileUsageByTorrent.get(torrent);
1590
1593
  if (usage && usage.size > 0) {
1594
+ logger.info(`torrent-pool: idle timer fired for "${name}" [${ih}] but refcount ${usage.size} >0 — keep`);
1591
1595
  return;
1592
1596
  }
1593
- this.#removeTorrent(torrent);
1597
+ logger.warn(`torrent-pool: idle TTL fired for "${name}" [${ih}] — removing torrent (reason=idle-ttl caller=scheduleIdleRemoval)`);
1598
+ this.#removeTorrent(torrent, "idle-ttl");
1594
1599
  }, TORRENT_IDLE_TTL_MS);
1595
1600
  timer.unref?.();
1596
1601
  this.#idleTimers.set(torrent, timer);
@@ -1615,12 +1620,20 @@ export class TorrentPool {
1615
1620
  * disk while the proxy keeps running. Best-effort.
1616
1621
  *
1617
1622
  * @param {import("webtorrent").Torrent} torrent
1623
+ * @param {string} [reason="unknown"] - Why removal was requested (idle-ttl, disk-cap, evict, api).
1618
1624
  * @returns {void}
1619
1625
  */
1620
- #removeTorrent(torrent) {
1626
+ #removeTorrent(torrent, reason = "unknown") {
1621
1627
  if (!torrent) {
1622
1628
  return;
1623
1629
  }
1630
+ const infoHash = String(torrent.infoHash ?? "?").slice(0, 8);
1631
+ const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
1632
+ const usage = this.fileUsageByTorrent.get(torrent);
1633
+ const refcount = usage ? usage.size : 0;
1634
+ const hasData = (() => { try { return torrentDownloadedBytes(torrent); } catch { return -1; } })();
1635
+ // Capture caller for diagnostics — not for control flow.
1636
+ const caller = new Error().stack?.split("\n")[2]?.trim() ?? "";
1624
1637
  // Drop it from the source→torrent map so a later request re-adds it.
1625
1638
  for (const [key, value] of this.torrents) {
1626
1639
  if (value === torrent) {
@@ -1631,14 +1644,14 @@ export class TorrentPool {
1631
1644
  this.fileUsageByTorrent.delete(torrent);
1632
1645
  this.#lastAccess.delete(torrent);
1633
1646
  this.#readPositionByTorrent.delete(torrent);
1634
- const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
1647
+ logger.warn(`torrent-pool: removing torrent "${name}" [${infoHash}] reason=${reason} refcount=${refcount} downloaded=${hasData}B caller=${caller}`);
1635
1648
  try {
1636
1649
  torrent.destroy({ destroyStore: true }, () => {
1637
- logger.info(`torrent-pool: removed idle torrent "${name}" and its store`);
1650
+ logger.info(`torrent-pool: removed torrent "${name}" [${infoHash}] reason=${reason} and its store`);
1638
1651
  });
1639
1652
  } catch (error) {
1640
1653
  const message = error instanceof Error ? error.message : String(error);
1641
- logger.warn(`torrent-pool: failed to remove idle torrent "${name}": ${message}`);
1654
+ logger.warn(`torrent-pool: failed to remove torrent "${name}" [${infoHash}] reason=${reason}: ${message}`);
1642
1655
  }
1643
1656
  }
1644
1657