@torrent-tv/proxy 2.9.25 → 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/CHANGELOG.md +8 -0
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -0
- package/openspec/changes/binary-distribution/proposal.md +53 -0
- package/openspec/changes/proxy-observability/tasks.md +2 -1
- 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/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/server.js +13 -0
- package/services/hls-session-manager.js +9 -2
- package/services/playback-planner.js +84 -4
- package/services/torrent-pool.js +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.26
|
|
2
|
+
|
|
3
|
+
- **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).
|
|
4
|
+
- **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.
|
|
5
|
+
- **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.
|
|
6
|
+
- **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.
|
|
7
|
+
- **Chore**: The announce log line strips the query string from the tracker URL — private trackers embed the account passkey there.
|
|
8
|
+
|
|
1
9
|
## 2.9.25
|
|
2
10
|
|
|
3
11
|
- **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,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
|
-
- [
|
|
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,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
|
@@ -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
|
-
|
|
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
|
package/services/torrent-pool.js
CHANGED
|
@@ -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
|
-
|
|
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 ?? "?"}`
|