@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.
- 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 +13 -0
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -0
- package/openspec/changes/binary-distribution/proposal.md +53 -0
- 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 +21 -0
- package/openspec/changes/track-selection/.openspec.yaml +2 -0
- package/openspec/changes/track-selection/design.md +47 -0
- package/openspec/changes/track-selection/proposal.md +45 -0
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -0
- package/openspec/changes/track-selection/tasks.md +20 -0
- package/openspec/config.yaml +38 -0
- package/package.json +1 -1
- package/routes/api/sources/files/get.js +43 -0
- package/routes/api/subtitles/get.js +129 -0
- package/routes/api/transcode-sessions/post.js +4 -1
- package/routes/health/get.js +3 -2
- package/routes/healthz/get.js +3 -2
- package/server.js +18 -2
- package/services/hls-session-manager.js +9 -2
- package/services/playback-planner.js +84 -4
- package/services/port-mapper.js +7 -0
- 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
|
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
|
@@ -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
|
});
|