@torrent-tv/proxy 2.9.25 → 2.9.27

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/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## 2.9.27
2
+
3
+ - **Fix**: A magnet whose infoHash matches a torrent already loaded in the pool no longer fails with 500 "Cannot add duplicate torrent" (scenario: one viewer opened the .torrent file, another pasted the magnet of the same content — different source keys, one swarm). The duplicate-add error now resolves to the already-loaded torrent (waiting for its metadata when it is itself still cold), so both source keys share the swarm. Found by a field test of the magnet flow.
4
+
5
+ ## 2.9.26
6
+
7
+ - **New**: Track inventory in the playback plan (OpenSpec change `track-selection`). The codec probe now parses EVERY input stream from the same ffmpeg banner, and the plan returns `audioTracks` and `subtitleTracks` — type-relative index, codec, language tag, `title` metadata, default disposition, and (for subtitles) a `textBased` flag (PGS/VobSub cannot become WebVTT).
8
+ - **New**: Audio track selection for HLS sessions. `POST /api/transcode-sessions` accepts `audioTrackIndex`; the session maps `0:a:N` instead of always the first track, and the index is part of the session key, so switching tracks creates a fresh session (server-side restart) while the old one expires via the idle TTL.
9
+ - **New**: Embedded subtitle extraction — `GET /api/subtitles?sourceKey&fileIndex&trackIndex` streams the chosen text subtitle track converted to WebVTT. Extraction reads the file up to the last cue, so on a cold torrent it drives the sequential download; callers must use a generous timeout. Non-text tracks (or a dead extraction) return 422 before any body.
10
+ - **New**: `GET /api/sources/:sourceKey/files` lists the files of a registered source. Groundwork for magnet-link input (OpenSpec change `magnet-input` in the server repo): the browser parses `.torrent` files locally, but a magnet's file list only exists in swarm metadata — this route resolves the torrent (waiting for metadata on a cold magnet) and returns the inventory.
11
+ - **Chore**: The announce log line strips the query string from the tracker URL — private trackers embed the account passkey there.
12
+
1
13
  ## 2.9.25
2
14
 
3
15
  - **New**: Observability (OpenSpec change `proxy-observability`). (1) `/healthz` and `/health` now include the proxy `version` — the addon shipped a stale proxy for a whole release and nothing could detect it remotely. (2) Peer-discovery diagnostics in `torrent-pool.js`: each added torrent logs its file count, `private` flag and tracker count; torrent-level `warning` events (tracker rejections/errors) are logged; every tracker announce response is logged with the seeder/leecher counts the tracker returned — so a zero-peer torrent is now explainable from the addon log. (3) Client-level WebTorrent warnings are logged too.
@@ -0,0 +1,2 @@
1
+ schema: spec-driven
2
+ created: 2026-07-06
@@ -0,0 +1,53 @@
1
+ # Proposal: Proxy as a single binary + service registration (research, queued)
2
+
3
+ ## Why
4
+
5
+ Bare-npm installation assumes Node.js on the host — a real barrier for the
6
+ non-technical proxy owners the pool model targets. Node's Single Executable
7
+ Applications (SEA, stable-ish since Node 20/21) allow shipping the proxy as
8
+ one self-contained binary per platform (win/linux/macOS, x64/arm64): download,
9
+ run, done. A binary can also offer — with the user's explicit consent — to
10
+ register itself as an auto-starting service, closing the "survives reboot"
11
+ gap that the HA addon solves today.
12
+
13
+ ## What Changes (research scope first)
14
+
15
+ - **SEA build pipeline**: `node --experimental-sea-config` + postject-injected
16
+ blob, one artifact per platform/arch, published as GitHub release assets
17
+ alongside the npm package.
18
+ - **Known hard parts to resolve in research**:
19
+ - Native addons (`node-datachannel`, `utp-native`) cannot live inside the
20
+ SEA blob — they must ship next to the binary or self-extract on first
21
+ run to a data dir.
22
+ - `ffmpeg-static`'s binary likewise ships alongside (or the system ffmpeg
23
+ is required, as the HA addon already does with `--ffmpeg-bin`).
24
+ - **Service registration (opt-in, explicit user action)**:
25
+ - Linux: `proxy install-service` writes a systemd unit and runs
26
+ `systemctl enable --now` (requires sudo — the consent step).
27
+ - Windows: register via `sc create` / a service wrapper (admin prompt =
28
+ consent).
29
+ - macOS: launchd plist in `~/Library/LaunchAgents` (user-level, no admin).
30
+ - Uninstall counterpart mandatory (`proxy uninstall-service`).
31
+ - Stays deployment-agnostic: the binary is a fourth distribution channel
32
+ next to HA addon, bare npm and Docker; no code paths may assume it.
33
+
34
+ ## Capabilities
35
+
36
+ ### New Capabilities
37
+
38
+ - `distribution`: how the proxy is packaged and installed on bare hosts.
39
+
40
+ ### Modified Capabilities
41
+
42
+ <!-- none -->
43
+
44
+ ## Impact
45
+
46
+ - Build/release tooling in this repo; a `service` CLI subcommand.
47
+ - No changes to runtime behaviour for existing channels.
48
+
49
+ ## Priority
50
+
51
+ MINIMAL — explicitly NOT part of the POC (owner decision, 2026-07-07).
52
+ Research for the eventual "pool of non-technical owners" product goal; do
53
+ not pick up while any POC work remains.
@@ -14,7 +14,8 @@
14
14
  ## 2. Release
15
15
 
16
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
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)
18
19
  - [ ] 2.3 After the addon updates: verify version via `/healthz`, watch the
19
20
  addon log for announce lines on a real torrent, confirm no SSDP
20
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.25",
3
+ "version": "2.9.27",
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,
package/server.js CHANGED
@@ -18,7 +18,9 @@ import { handleHealthGet } from "./routes/health/get.js";
18
18
  import { handleHealthzGet } from "./routes/healthz/get.js";
19
19
  import { handleApiSourcesPost } from "./routes/api/sources/post.js";
20
20
  import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
21
+ import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
21
22
  import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
23
+ import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
22
24
  import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
23
25
  import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
24
26
  import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
@@ -132,9 +134,20 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }
132
134
  app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
133
135
  handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
134
136
  );
137
+ app.get("/api/sources/:sourceKey/files", async (req, reply) =>
138
+ handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
139
+ );
135
140
  app.post("/api/playback-plan", async (req, reply) =>
136
141
  handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
137
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
+ );
138
151
  app.get("/stream", async (req, reply) =>
139
152
  handleStreamGet(req, reply, { sourceRegistry, torrentPool })
140
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",
@@ -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
@@ -145,7 +145,10 @@ export class TorrentPool {
145
145
  const tracker = torrent.discovery?.tracker;
146
146
  if (tracker && typeof tracker.on === "function") {
147
147
  tracker.on("update", (data) => {
148
- const announceUrl = typeof data?.announce === "string" ? data.announce : "?";
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(/\?.*$/, "") : "?";
149
152
  logger.info(
150
153
  `torrent-pool: [${label}] announce ${announceUrl}: ` +
151
154
  `seeders=${data?.complete ?? "?"} leechers=${data?.incomplete ?? "?"}`
@@ -184,6 +187,27 @@ export class TorrentPool {
184
187
  const promise = new Promise((resolve, reject) => {
185
188
  const onError = (error) => {
186
189
  this.client.off("error", onError);
190
+ // The same content can arrive as a .torrent AND as a magnet —
191
+ // different pool keys, one swarm. WebTorrent rejects the duplicate
192
+ // add; resolve with the already-loaded torrent instead of failing.
193
+ const message = error instanceof Error ? error.message : String(error);
194
+ const dupMatch = /duplicate torrent ([0-9a-f]{40})/i.exec(message);
195
+ if (dupMatch) {
196
+ const existing = this.client.torrents.find((t) => t?.infoHash === dupMatch[1]);
197
+ if (existing) {
198
+ const settle = () => {
199
+ this.torrents.set(key, existing);
200
+ this.#pending.delete(key);
201
+ resolve(existing);
202
+ };
203
+ if (existing.ready) {
204
+ settle();
205
+ } else {
206
+ existing.once("ready", settle);
207
+ }
208
+ return;
209
+ }
210
+ }
187
211
  this.#pending.delete(key);
188
212
  reject(error);
189
213
  };