@torrent-tv/proxy 2.9.24 → 2.9.26

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.
Files changed (35) hide show
  1. package/.claude/commands/opsx/apply.md +155 -0
  2. package/.claude/commands/opsx/archive.md +160 -0
  3. package/.claude/commands/opsx/explore.md +174 -0
  4. package/.claude/commands/opsx/propose.md +109 -0
  5. package/.claude/commands/opsx/sync.md +143 -0
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -0
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -0
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -0
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -0
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -0
  11. package/CHANGELOG.md +13 -0
  12. package/openspec/changes/binary-distribution/.openspec.yaml +2 -0
  13. package/openspec/changes/binary-distribution/proposal.md +53 -0
  14. package/openspec/changes/proxy-observability/.openspec.yaml +2 -0
  15. package/openspec/changes/proxy-observability/design.md +38 -0
  16. package/openspec/changes/proxy-observability/proposal.md +49 -0
  17. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -0
  18. package/openspec/changes/proxy-observability/tasks.md +21 -0
  19. package/openspec/changes/track-selection/.openspec.yaml +2 -0
  20. package/openspec/changes/track-selection/design.md +47 -0
  21. package/openspec/changes/track-selection/proposal.md +45 -0
  22. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -0
  23. package/openspec/changes/track-selection/tasks.md +20 -0
  24. package/openspec/config.yaml +38 -0
  25. package/package.json +1 -1
  26. package/routes/api/sources/files/get.js +43 -0
  27. package/routes/api/subtitles/get.js +129 -0
  28. package/routes/api/transcode-sessions/post.js +4 -1
  29. package/routes/health/get.js +3 -2
  30. package/routes/healthz/get.js +3 -2
  31. package/server.js +18 -2
  32. package/services/hls-session-manager.js +9 -2
  33. package/services/playback-planner.js +84 -4
  34. package/services/port-mapper.js +7 -0
  35. package/services/torrent-pool.js +50 -0
@@ -11,6 +11,54 @@ import { spawn } from "node:child_process";
11
11
  /** Audio codecs that browsers can decode natively without transcoding. */
12
12
  const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
13
13
 
14
+ /** Subtitle codecs that can be converted to WebVTT (text-based). */
15
+ const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
16
+
17
+ /**
18
+ * Parse every stream from the ffmpeg `-i` banner: type, codec, language tag,
19
+ * default disposition and (when present) the stream's `title` metadata line.
20
+ *
21
+ * @param {string} ffmpegOutput
22
+ * @returns {Array<{ streamIndex: number, type: string, codec: string, language: string, title: string, isDefault: boolean }>}
23
+ */
24
+ function parseStreams(ffmpegOutput) {
25
+ // Only the Input section: ffmpeg prints Stream lines for the null OUTPUT
26
+ // too (wrapped_avframe / pcm_s16le), which would duplicate every track.
27
+ const inputSection = ffmpegOutput.split(/^(?:Output #|Stream mapping:)/m)[0] ?? ffmpegOutput;
28
+ const lines = inputSection.split(/\r?\n/);
29
+ const streams = [];
30
+ let current = null;
31
+ for (const line of lines) {
32
+ const streamMatch = line.match(
33
+ /^\s*Stream #0:(\d+)(?:\[[^\]]*\])?(?:\(([A-Za-z0-9]{2,3})\))?: (Audio|Video|Subtitle): ([A-Za-z0-9_]+)/
34
+ );
35
+ if (streamMatch) {
36
+ current = {
37
+ streamIndex: Number(streamMatch[1]),
38
+ type: streamMatch[3].toLowerCase(),
39
+ codec: String(streamMatch[4]).toLowerCase(),
40
+ language: (streamMatch[2] ?? "").toLowerCase(),
41
+ title: "",
42
+ isDefault: /\(default\)/.test(line)
43
+ };
44
+ streams.push(current);
45
+ continue;
46
+ }
47
+ if (current) {
48
+ const titleMatch = line.match(/^\s+title\s*:\s*(.+)$/);
49
+ if (titleMatch && current.title.length === 0) {
50
+ current.title = titleMatch[1].trim();
51
+ continue;
52
+ }
53
+ // A new top-level section (non-indented line) ends the stream's block.
54
+ if (!/^\s/.test(line)) {
55
+ current = null;
56
+ }
57
+ }
58
+ }
59
+ return streams;
60
+ }
61
+
14
62
  /**
15
63
  * Parse audio and video codec names from ffmpeg stderr output.
16
64
  *
@@ -28,11 +76,38 @@ function parseStreamCodecs(ffmpegOutput) {
28
76
  Number(durationMatch[1]) * 3600 + Number(durationMatch[2]) * 60 + Number(durationMatch[3]);
29
77
  durationSeconds = Number.isFinite(value) ? value : 0;
30
78
  }
79
+ const streams = parseStreams(ffmpegOutput);
80
+ const audioTracks = streams
81
+ .filter((s) => s.type === "audio")
82
+ .map((s, i) => ({
83
+ // Type-relative index — what ffmpeg's `-map 0:a:N` selects.
84
+ index: i,
85
+ streamIndex: s.streamIndex,
86
+ codec: s.codec,
87
+ language: s.language,
88
+ title: s.title,
89
+ isDefault: s.isDefault
90
+ }));
91
+ const subtitleTracks = streams
92
+ .filter((s) => s.type === "subtitle")
93
+ .map((s, i) => ({
94
+ // Type-relative index — what ffmpeg's `-map 0:s:N` selects.
95
+ index: i,
96
+ streamIndex: s.streamIndex,
97
+ codec: s.codec,
98
+ language: s.language,
99
+ title: s.title,
100
+ isDefault: s.isDefault,
101
+ // Image-based subtitles (PGS/VobSub) cannot become WebVTT.
102
+ textBased: TEXT_SUBTITLE_CODECS.has(s.codec)
103
+ }));
31
104
  return {
32
105
  audioCodec: audioMatch ? String(audioMatch[1]).toLowerCase() : "",
33
106
  videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
34
107
  container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
35
- durationSeconds
108
+ durationSeconds,
109
+ audioTracks,
110
+ subtitleTracks
36
111
  };
37
112
  }
38
113
 
@@ -213,7 +288,9 @@ export function createPlaybackPlanner({
213
288
  audioCodec: "",
214
289
  videoCodec: "",
215
290
  container: "",
216
- durationSeconds: 0
291
+ durationSeconds: 0,
292
+ audioTracks: [],
293
+ subtitleTracks: []
217
294
  };
218
295
  cache.set(cacheKey, plan);
219
296
  return plan;
@@ -239,7 +316,7 @@ export function createPlaybackPlanner({
239
316
  await torrentPool.prefetchFileEdges(torrent, fileIndex);
240
317
  probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
241
318
  }
242
- const { audioCodec, videoCodec, container, durationSeconds } = probe;
319
+ const { audioCodec, videoCodec, container, durationSeconds, audioTracks, subtitleTracks } = probe;
243
320
  const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
244
321
 
245
322
  // `mode` is advisory only (audio-codec based). The browser makes the
@@ -253,7 +330,10 @@ export function createPlaybackPlanner({
253
330
  audioCodec,
254
331
  videoCodec,
255
332
  container,
256
- durationSeconds
333
+ durationSeconds,
334
+ // Full track inventory for the browser's audio/subtitle menus.
335
+ audioTracks: audioTracks ?? [],
336
+ subtitleTracks: subtitleTracks ?? []
257
337
  };
258
338
  // Only cache a plan whose codecs were actually detected. An empty probe is
259
339
  // a "header not downloaded yet" signal, not a valid result — caching it
@@ -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,53 @@ 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
+ // Private trackers embed the account passkey in the announce URL —
149
+ // strip the query string before logging.
150
+ const announceUrl =
151
+ typeof data?.announce === "string" ? data.announce.replace(/\?.*$/, "") : "?";
152
+ logger.info(
153
+ `torrent-pool: [${label}] announce ${announceUrl}: ` +
154
+ `seeders=${data?.complete ?? "?"} leechers=${data?.incomplete ?? "?"}`
155
+ );
156
+ });
157
+ } else {
158
+ logger.info(`torrent-pool: [${label}] tracker client not exposed; announce results not logged`);
159
+ }
113
160
  }
114
161
 
115
162
  /**
@@ -148,6 +195,9 @@ export class TorrentPool {
148
195
  this.client.off("error", onError);
149
196
  this.torrents.set(key, readyTorrent);
150
197
  this.#pending.delete(key);
198
+ // Key layout is `${sourceType}:${sha1}`; log with the sha1 prefix so
199
+ // lines correlate with the [stats] source key.
200
+ this.#attachSwarmDiagnostics(key.split(":")[1]?.slice(0, 8) ?? key, readyTorrent);
151
201
  resolve(readyTorrent);
152
202
  });
153
203
  });