@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
@@ -0,0 +1,49 @@
1
+ # Proposal: Proxy observability (queued for the next proxy release, 2.9.25)
2
+
3
+ ## Why
4
+
5
+ Three diagnosis gaps surfaced while debugging the 2026-07-06 mobile playback
6
+ failures:
7
+
8
+ 1. `/healthz` and `/health` return only `{"ok":true}` — there is no way to
9
+ see the running proxy version remotely (the addon shipped a stale proxy
10
+ for a whole release and nothing detected it). The server already returns
11
+ `version` in its healthz; the proxy should match.
12
+ 2. Tracker announces are silent: a torrent with zero peers gives no clue
13
+ whether the tracker rejected the announce (e.g. a private tracker's
14
+ client whitelist), returned an empty peer list, or was unreachable.
15
+ WebTorrent surfaces these as `warning` events that nothing logs.
16
+ 3. `MaxListenersExceededWarning` for `[Ssdp]` floods the log — the two
17
+ port-mapper instances (TCP + UDP) plus WebTorrent's own nat-api attach
18
+ listeners to a shared SSDP emitter.
19
+
20
+ ## What Changes
21
+
22
+ - `/healthz` and `/health` include the proxy `version` (from package.json),
23
+ keeping the current `ok` semantics.
24
+ - Torrent-level `warning` events (tracker errors and rejections) are logged;
25
+ each tracker announce result is logged with the peer count returned.
26
+ Where cheaply available, peer origin (tracker / DHT / PEX) is tagged in
27
+ the existing `[stats]` line.
28
+ - SSDP listener leak fixed (raise the max-listeners on the shared emitter or
29
+ share one discovery instance between the two port mappers).
30
+
31
+ ## Capabilities
32
+
33
+ ### New Capabilities
34
+
35
+ - `observability`: health/version reporting and peer-discovery diagnostics.
36
+
37
+ ### Modified Capabilities
38
+
39
+ <!-- none yet — openspec/specs is empty in this repo -->
40
+
41
+ ## Impact
42
+
43
+ - `routes/healthz/get.js`, `routes/health/get.js` (or their handlers) —
44
+ version field.
45
+ - `services/torrent-pool.js` — warning/announce logging, stats origin tags.
46
+ - `services/port-mapper.js` — SSDP listener fix.
47
+ - CHANGELOG entry at current package.json version + 1 patch (2.9.25);
48
+ requires the usual ha-addon version bump (0.2.47) after `npm run patch`
49
+ (npm publish needs the user's npm key).
@@ -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,21 @@
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
+ - [x] 2.2 `npm run patch` (needs npm auth), then ha-addon bump 0.2.47 + push
18
+ (2.9.25 published by the owner — npm 2FA; addon 0.2.47 pushed after)
19
+ - [ ] 2.3 After the addon updates: verify version via `/healthz`, watch the
20
+ addon log for announce lines on a real torrent, confirm no SSDP
21
+ warnings
@@ -0,0 +1,2 @@
1
+ schema: spec-driven
2
+ created: 2026-07-07
@@ -0,0 +1,47 @@
1
+ # Design: Track inventory, audio selection and embedded-subtitle extraction
2
+
3
+ ## Context
4
+
5
+ The probe already captures the full ffmpeg `-i` banner; the HLS session
6
+ manager already restarts ffmpeg per (source, file, settings) key; the
7
+ `/stream` route already drives prioritised sequential download. All three
8
+ features ride those mechanisms.
9
+
10
+ ## Goals / Non-Goals
11
+
12
+ **Goals:** expose every track; select audio server-side; deliver embedded
13
+ text subtitles as WebVTT.
14
+
15
+ **Non-Goals:** seamless (no-restart) audio switching via HLS alternate
16
+ renditions; image-based subtitles (PGS/VobSub — needs OCR or burn-in);
17
+ subtitle extraction that avoids downloading the file (impossible: cues are
18
+ interleaved across the whole container).
19
+
20
+ ## Decisions
21
+
22
+ 1. **Parse tracks from the existing probe output** (zero extra probe cost).
23
+ The scanner reads only the Input section — ffmpeg prints Stream lines for
24
+ the null output too, which would duplicate every track (caught against
25
+ real output). Titles come from each stream's `title` metadata line.
26
+ 2. **Audio switch = new session.** `audioTrackIndex` joins the session key;
27
+ the old session dies via the existing idle TTL. Reuses the proven
28
+ seek-restart machinery instead of building HLS alternate renditions;
29
+ the cost is a few seconds' gap on switch — acceptable v1.
30
+ 3. **Extraction as a streaming route.** ffmpeg writes WebVTT to stdout piped
31
+ into the HTTP response; the first stdout chunk decides 200-vs-422 (a
32
+ non-text track dies before producing output). Client disconnect kills
33
+ ffmpeg; a 30-minute hard cap guards dead swarms. The transport layer's
34
+ 60 s request timeout must be raised per-request by the browser (done in
35
+ the paired server change).
36
+ 4. **Accepted v1 cost:** extraction reads to the last cue → cold torrents
37
+ download while extracting. For the transcode path the file downloads
38
+ anyway; for direct play this is extra traffic the viewer opted into by
39
+ picking a subtitle.
40
+
41
+ ## Risks / Trade-offs
42
+
43
+ - [Extraction competes with playback for piece priority] → both readers move
44
+ the 8 MB critical window; sequential download serves both. Field-watch; if
45
+ playback stalls appear, throttle extraction reads later.
46
+ - [Stream-line format drift across ffmpeg versions] → regex kept permissive;
47
+ a parse miss degrades to an empty inventory (menus simply do not appear).
@@ -0,0 +1,45 @@
1
+ # Proposal: Track inventory, audio selection and embedded-subtitle extraction
2
+
3
+ ## Why
4
+
5
+ Torrents routinely carry several audio languages and embedded subtitles
6
+ (the owner's own test MKVs embed ASS subtitles), but the proxy exposed only
7
+ "the first audio track" and no subtitles at all: the probe reported a single
8
+ audio/video codec pair, the HLS session hard-mapped `0:a:0`, and embedded
9
+ subtitle streams were unreachable by the browser.
10
+
11
+ ## What Changes
12
+
13
+ - **Probe returns the full track inventory**: `audioTracks` and
14
+ `subtitleTracks` in the playback plan (type-relative index, codec,
15
+ language, `title` metadata, default flag, `textBased` for subtitles) —
16
+ parsed from the same single ffmpeg banner, no extra probe cost.
17
+ - **Audio selection**: `POST /api/transcode-sessions` accepts
18
+ `audioTrackIndex`; the ffmpeg map becomes `0:a:N` and the index joins the
19
+ session key (switch = fresh session via the existing restart machinery).
20
+ - **Embedded subtitle extraction**: `GET /api/subtitles` streams a chosen
21
+ text subtitle track as WebVTT (ffmpeg `-map 0:s:N -f webvtt`). Image-based
22
+ tracks (PGS/VobSub) are refused with 422. Known cost, accepted for v1:
23
+ extraction reads the file to the last cue, so a cold torrent downloads
24
+ sequentially while extracting.
25
+ - Announce log masks the tracker query string (passkey).
26
+
27
+ ## Capabilities
28
+
29
+ ### New Capabilities
30
+
31
+ - `track-selection`: track inventory in the plan, audio mapping, subtitle
32
+ extraction.
33
+
34
+ ### Modified Capabilities
35
+
36
+ - `observability`: announce log masks the passkey (delta note; the change is
37
+ still unarchived so the edit lands there).
38
+
39
+ ## Impact
40
+
41
+ - `services/playback-planner.js`, `services/hls-session-manager.js`,
42
+ `routes/api/transcode-sessions/post.js`, new `routes/api/subtitles/get.js`,
43
+ `server.js` wiring, `services/torrent-pool.js` (log masking).
44
+ - Pairs with the server-side `track-selection-ui` change; requires the usual
45
+ ha-addon bump.
@@ -0,0 +1,41 @@
1
+ # track-selection — delta spec
2
+
3
+ ## ADDED Requirements
4
+
5
+ ### Requirement: The playback plan lists every track
6
+ The playback plan SHALL include `audioTracks` and `subtitleTracks` arrays
7
+ parsed from the probe: for each track its type-relative index (what
8
+ `-map 0:a:N` / `0:s:N` selects), codec, language tag, `title` metadata,
9
+ default disposition, and — for subtitles — a `textBased` flag. Output-side
10
+ streams of the probe run MUST NOT leak into the inventory.
11
+
12
+ #### Scenario: MKV with an embedded subtitle
13
+ - **WHEN** the plan is requested for an MKV with one video, one audio and one
14
+ ASS subtitle stream
15
+ - **THEN** the plan lists exactly one audio track and one subtitle track with
16
+ `textBased: true`
17
+
18
+ ### Requirement: Audio track selection
19
+ `POST /api/transcode-sessions` SHALL accept `audioTrackIndex` (type-relative,
20
+ default 0) and the session SHALL map that audio track. The index SHALL be
21
+ part of the session identity so different tracks never share a session.
22
+
23
+ #### Scenario: Second audio track
24
+ - **WHEN** a session is created with `audioTrackIndex: 1`
25
+ - **THEN** ffmpeg maps `0:a:1` and a later request with `audioTrackIndex: 0`
26
+ gets a different session
27
+
28
+ ### Requirement: Embedded subtitles as WebVTT
29
+ `GET /api/subtitles?sourceKey&fileIndex&trackIndex` SHALL stream the chosen
30
+ embedded TEXT subtitle track converted to WebVTT, starting the response as
31
+ soon as ffmpeg produces output. A track that produces no output (image-based
32
+ or broken) SHALL return 422 before any body. Extraction MUST stop when the
33
+ client disconnects.
34
+
35
+ #### Scenario: Text track extracted
36
+ - **WHEN** the client requests a text subtitle track
37
+ - **THEN** the response is `text/vtt` starting with `WEBVTT` and real cues
38
+
39
+ #### Scenario: Image-based track refused
40
+ - **WHEN** the client requests a PGS/VobSub track
41
+ - **THEN** the proxy responds 422 with an explanatory error
@@ -0,0 +1,20 @@
1
+ # Tasks: Track inventory, audio selection and embedded-subtitle extraction
2
+
3
+ ## 1. Implementation
4
+
5
+ - [x] 1.1 playback-planner: input-section stream scanner (index, codec,
6
+ language, title, default, textBased); `audioTracks`/`subtitleTracks`
7
+ in the plan (verified against real ffmpeg output from the owner's MKV:
8
+ hevc + flac + ass(eng) parsed, output streams excluded)
9
+ - [x] 1.2 hls-session-manager: `audioTrackIndex` option → `-map 0:a:N`,
10
+ part of the session key; transcode-sessions route passthrough
11
+ - [x] 1.3 routes/api/subtitles/get.js: streaming WebVTT extraction, 422 on
12
+ no-output tracks, kill on client disconnect, 30 min cap (verified:
13
+ real cues extracted from the embedded ASS track over the LAN proxy)
14
+ - [x] 1.4 torrent-pool: mask the announce query string (passkey)
15
+
16
+ ## 2. Release
17
+
18
+ - [ ] 2.1 `npm run patch` (2.9.26; needs npm 2FA), then ha-addon 0.2.48
19
+ - [ ] 2.2 After the addon updates: verify plan lists tracks and
20
+ /api/subtitles serves VTT from the addon proxy
@@ -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.26",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * List the files of a registered source (torrent file OR magnet).
3
+ *
4
+ * GET /api/sources/:sourceKey/files
5
+ *
6
+ * The browser parses `.torrent` files locally, but a magnet URI carries no
7
+ * file list — the metadata comes from the swarm. This route resolves the
8
+ * torrent (waiting for metadata on a cold magnet; callers should use a
9
+ * generous timeout) and returns the file inventory.
10
+ *
11
+ * @param {import("fastify").FastifyRequest} req
12
+ * @param {import("fastify").FastifyReply} reply
13
+ * @param {{
14
+ * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
15
+ * torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
16
+ * }} deps
17
+ * @returns {Promise<void>}
18
+ */
19
+ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool }) {
20
+ const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
21
+ if (!sourceKey) {
22
+ return reply.code(400).send({ error: "sourceKey is required." });
23
+ }
24
+ const sourceRecord = sourceRegistry.get(sourceKey);
25
+ if (!sourceRecord) {
26
+ return reply.code(404).send({ error: "Source key was not found." });
27
+ }
28
+
29
+ const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
30
+ const files = (torrent.files ?? []).map((file, index) => ({
31
+ index,
32
+ name: file?.name ?? "",
33
+ // Path relative to the torrent root (matches the browser's own parser).
34
+ relativePath: file?.path ?? file?.name ?? "",
35
+ length: Number.isFinite(file?.length) ? file.length : 0
36
+ }));
37
+
38
+ return reply.send({
39
+ name: torrent.name ?? "",
40
+ infoHash: torrent.infoHash ?? "",
41
+ files
42
+ });
43
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Extract an embedded text subtitle track from a torrent file as WebVTT.
3
+ *
4
+ * GET /api/subtitles?sourceKey=...&fileIndex=N&trackIndex=M
5
+ *
6
+ * `trackIndex` is the TYPE-RELATIVE subtitle stream index (what ffmpeg's
7
+ * `-map 0:s:M` selects), as reported by the playback plan's
8
+ * `subtitleTracks[].index`.
9
+ *
10
+ * The response streams while ffmpeg produces it. Extraction has to read the
11
+ * file up to the last cue, so on a cold torrent this drives (and waits for)
12
+ * the sequential download — callers must use a generous timeout.
13
+ *
14
+ * @param {import("fastify").FastifyRequest} req
15
+ * @param {import("fastify").FastifyReply} reply
16
+ * @param {{
17
+ * sourceRegistry: ReturnType<import("../../../store/source-registry.js").createSourceRegistry>,
18
+ * torrentPool: import("../../../services/torrent-pool.js").TorrentPool,
19
+ * ffmpegBin: string,
20
+ * localBaseUrl: string
21
+ * }} deps
22
+ * @returns {Promise<void>}
23
+ */
24
+
25
+ import { spawn } from "node:child_process";
26
+
27
+ // Safety cap: no extraction may outlive this (a dead swarm would otherwise
28
+ // hold the ffmpeg process forever).
29
+ const EXTRACTION_TIMEOUT_MS = 30 * 60 * 1000;
30
+
31
+ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torrentPool, ffmpegBin, localBaseUrl }) {
32
+ const query = req.query ?? {};
33
+ const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey.trim() : "";
34
+ const fileIndex = Number(query.fileIndex);
35
+ const trackIndex = Number(query.trackIndex);
36
+
37
+ if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0 || !Number.isInteger(trackIndex) || trackIndex < 0) {
38
+ return reply.code(400).send({ error: "sourceKey, fileIndex and trackIndex are required." });
39
+ }
40
+
41
+ const sourceRecord = sourceRegistry.get(sourceKey);
42
+ if (!sourceRecord) {
43
+ return reply.code(404).send({ error: "Source key was not found." });
44
+ }
45
+ const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
46
+ if (!torrent.files[fileIndex]) {
47
+ return reply.code(404).send({ error: "File index was not found in torrent." });
48
+ }
49
+
50
+ const inputUrl = new URL("/stream", `${localBaseUrl}/`);
51
+ inputUrl.searchParams.set("sourceKey", sourceKey);
52
+ inputUrl.searchParams.set("fileIndex", String(fileIndex));
53
+
54
+ const ffmpeg = spawn(
55
+ ffmpegBin,
56
+ [
57
+ "-hide_banner",
58
+ "-loglevel",
59
+ "error",
60
+ "-i",
61
+ inputUrl.toString(),
62
+ "-map",
63
+ `0:s:${trackIndex}`,
64
+ "-f",
65
+ "webvtt",
66
+ "pipe:1"
67
+ ],
68
+ { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }
69
+ );
70
+
71
+ let stderr = "";
72
+ ffmpeg.stderr.on("data", (chunk) => {
73
+ if (stderr.length < 4096) {
74
+ stderr += String(chunk);
75
+ }
76
+ });
77
+
78
+ const killTimer = setTimeout(() => {
79
+ if (!ffmpeg.killed) {
80
+ ffmpeg.kill("SIGKILL");
81
+ }
82
+ }, EXTRACTION_TIMEOUT_MS);
83
+ killTimer.unref?.();
84
+
85
+ // Stop extracting when the client goes away.
86
+ req.raw.on("close", () => {
87
+ clearTimeout(killTimer);
88
+ if (!ffmpeg.killed) {
89
+ ffmpeg.kill("SIGTERM");
90
+ }
91
+ });
92
+
93
+ // Distinguish "bad track / not text-based" (ffmpeg dies before any output)
94
+ // from a mid-stream failure (headers already sent; the stream just ends).
95
+ const firstChunk = await new Promise((resolve) => {
96
+ let settled = false;
97
+ const settle = (value) => {
98
+ if (!settled) {
99
+ settled = true;
100
+ resolve(value);
101
+ }
102
+ };
103
+ ffmpeg.stdout.once("data", (chunk) => settle(chunk));
104
+ ffmpeg.once("exit", () => settle(null));
105
+ ffmpeg.once("error", () => settle(null));
106
+ });
107
+
108
+ if (firstChunk === null) {
109
+ clearTimeout(killTimer);
110
+ return reply
111
+ .code(422)
112
+ .send({ error: `Subtitle track could not be extracted: ${stderr.trim() || "no output from ffmpeg"}` });
113
+ }
114
+
115
+ reply.raw.writeHead(200, {
116
+ "content-type": "text/vtt; charset=utf-8",
117
+ "cache-control": "no-store",
118
+ "access-control-allow-origin": "*"
119
+ });
120
+ reply.raw.write(firstChunk);
121
+ ffmpeg.stdout.pipe(reply.raw);
122
+ await new Promise((resolve) => {
123
+ ffmpeg.stdout.once("end", resolve);
124
+ ffmpeg.once("error", resolve);
125
+ });
126
+ clearTimeout(killTimer);
127
+ reply.raw.end();
128
+ return reply;
129
+ }
@@ -34,6 +34,7 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
34
34
  const targetWidth = Number(payload.targetWidth);
35
35
  const targetHeight = Number(payload.targetHeight);
36
36
  const startPositionSeconds = Number(payload.startPositionSeconds);
37
+ const audioTrackIndex = Number(payload.audioTrackIndex);
37
38
 
38
39
  if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
39
40
  return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
@@ -52,7 +53,9 @@ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionMan
52
53
  startPositionSeconds:
53
54
  Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
54
55
  ? startPositionSeconds
55
- : 0
56
+ : 0,
57
+ audioTrackIndex:
58
+ Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0
56
59
  });
57
60
  return reply.send({
58
61
  sessionId: session.id,
@@ -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,12 +12,15 @@ 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";
18
19
  import { handleApiSourcesPost } from "./routes/api/sources/post.js";
19
20
  import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
21
+ import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
20
22
  import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
23
+ import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
21
24
  import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
22
25
  import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
23
26
  import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
@@ -32,6 +35,8 @@ import { logger } from "./utils/logger.js";
32
35
 
33
36
  const __filename = fileURLToPath(import.meta.url);
34
37
  const __dirname = path.dirname(__filename);
38
+ const require = createRequire(import.meta.url);
39
+ const { version } = require("./package.json");
35
40
  const publicRoot = path.resolve(__dirname, "./public");
36
41
 
37
42
  /**
@@ -121,17 +126,28 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }
121
126
  torrentPool
122
127
  });
123
128
 
124
- app.get("/health", async (req, reply) => handleHealthGet(req, reply));
125
- app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply));
129
+ app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
130
+ app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
126
131
  app.post("/api/sources", async (req, reply) =>
127
132
  handleApiSourcesPost(req, reply, { sourceRegistry })
128
133
  );
129
134
  app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
130
135
  handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
131
136
  );
137
+ app.get("/api/sources/:sourceKey/files", async (req, reply) =>
138
+ handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
139
+ );
132
140
  app.post("/api/playback-plan", async (req, reply) =>
133
141
  handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
134
142
  );
143
+ app.get("/api/subtitles", async (req, reply) =>
144
+ handleApiSubtitlesGet(req, reply, {
145
+ sourceRegistry,
146
+ torrentPool,
147
+ ffmpegBin,
148
+ localBaseUrl: hlsSessionManager.localBaseUrl
149
+ })
150
+ );
135
151
  app.get("/stream", async (req, reply) =>
136
152
  handleStreamGet(req, reply, { sourceRegistry, torrentPool })
137
153
  );
@@ -642,6 +642,7 @@ export class HlsSessionManager {
642
642
  * @param {number} [options.targetWidth=0] - Target video width (0 = keep source).
643
643
  * @param {number} [options.targetHeight=0] - Target video height (0 = keep source).
644
644
  * @param {number} [options.startPositionSeconds=0] - Seek start position in seconds.
645
+ * @param {number} [options.audioTrackIndex=0] - Type-relative audio track to map (0:a:N).
645
646
  * @returns {Promise<HlsSession>}
646
647
  */
647
648
  async createOrGetSession({
@@ -653,7 +654,8 @@ export class HlsSessionManager {
653
654
  fileName = "",
654
655
  targetWidth = 0,
655
656
  targetHeight = 0,
656
- startPositionSeconds = 0
657
+ startPositionSeconds = 0,
658
+ audioTrackIndex = 0
657
659
  }) {
658
660
  if (!this.enabled) {
659
661
  const error = new Error("Audio transcoding is disabled on this proxy.");
@@ -669,11 +671,14 @@ export class HlsSessionManager {
669
671
  Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
670
672
  ? Math.round(startPositionSeconds / 10) * 10
671
673
  : 0;
674
+ const normalizedAudioTrack =
675
+ Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0;
672
676
  const sourceMapKey = [
673
677
  sourceKey,
674
678
  String(fileIndex),
675
679
  transcodeVideo ? "video" : "audio",
676
680
  transcodeAudio ? "a1" : "a0",
681
+ `t${normalizedAudioTrack}`,
677
682
  String(normalizedTargetWidth),
678
683
  String(normalizedTargetHeight),
679
684
  String(normalizedStartPosition)
@@ -783,6 +788,7 @@ export class HlsSessionManager {
783
788
  fileIndex,
784
789
  transcodeVideo,
785
790
  transcodeAudio,
791
+ audioTrackIndex: normalizedAudioTrack,
786
792
  targetWidth: normalizedTargetWidth,
787
793
  targetHeight: normalizedTargetHeight,
788
794
  sourceWidth,
@@ -1043,7 +1049,8 @@ export class HlsSessionManager {
1043
1049
  "-map",
1044
1050
  "0:v:0?",
1045
1051
  "-map",
1046
- "0:a:0?",
1052
+ // Type-relative audio track chosen by the viewer (default 0).
1053
+ `0:a:${session.audioTrackIndex ?? 0}?`,
1047
1054
  ...videoCodecArgs,
1048
1055
  ...audioCodecArgs,
1049
1056
  "-f",