@torrent-tv/proxy 2.9.24 → 2.9.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/opsx/apply.md +155 -0
- package/.claude/commands/opsx/archive.md +160 -0
- package/.claude/commands/opsx/explore.md +174 -0
- package/.claude/commands/opsx/propose.md +109 -0
- package/.claude/commands/opsx/sync.md +143 -0
- package/.claude/skills/openspec-apply-change/SKILL.md +159 -0
- package/.claude/skills/openspec-archive-change/SKILL.md +117 -0
- package/.claude/skills/openspec-explore/SKILL.md +289 -0
- package/.claude/skills/openspec-propose/SKILL.md +113 -0
- package/.claude/skills/openspec-sync-specs/SKILL.md +147 -0
- package/CHANGELOG.md +13 -0
- package/openspec/changes/binary-distribution/.openspec.yaml +2 -0
- package/openspec/changes/binary-distribution/proposal.md +53 -0
- package/openspec/changes/proxy-observability/.openspec.yaml +2 -0
- package/openspec/changes/proxy-observability/design.md +38 -0
- package/openspec/changes/proxy-observability/proposal.md +49 -0
- package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -0
- package/openspec/changes/proxy-observability/tasks.md +21 -0
- package/openspec/changes/track-selection/.openspec.yaml +2 -0
- package/openspec/changes/track-selection/design.md +47 -0
- package/openspec/changes/track-selection/proposal.md +45 -0
- package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -0
- package/openspec/changes/track-selection/tasks.md +20 -0
- package/openspec/config.yaml +38 -0
- package/package.json +1 -1
- package/routes/api/sources/files/get.js +43 -0
- package/routes/api/subtitles/get.js +129 -0
- package/routes/api/transcode-sessions/post.js +4 -1
- package/routes/health/get.js +3 -2
- package/routes/healthz/get.js +3 -2
- package/server.js +18 -2
- package/services/hls-session-manager.js +9 -2
- package/services/playback-planner.js +84 -4
- package/services/port-mapper.js +7 -0
- package/services/torrent-pool.js +50 -0
|
@@ -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,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
|
@@ -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/routes/health/get.js
CHANGED
|
@@ -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
|
}
|
package/routes/healthz/get.js
CHANGED
|
@@ -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
|
-
|
|
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",
|