@torrent-tv/proxy 2.9.27 → 2.9.30
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 +10 -0
- package/bin/cli.js +9 -1
- package/openspec/changes/disk-cap/.openspec.yaml +2 -0
- package/openspec/changes/disk-cap/proposal.md +37 -0
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -0
- package/openspec/changes/disk-cap/tasks.md +19 -0
- package/openspec/changes/subtitle-language/.openspec.yaml +2 -0
- package/openspec/changes/subtitle-language/proposal.md +50 -0
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -0
- package/openspec/changes/subtitle-language/tasks.md +26 -0
- package/openspec/changes/transcode-quality/.openspec.yaml +2 -0
- package/openspec/changes/transcode-quality/proposal.md +51 -0
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +37 -0
- package/openspec/changes/transcode-quality/tasks.md +34 -0
- package/package.json +2 -1
- package/routes/api/sources/files/get.js +47 -4
- package/routes/api/subtitles/get.js +92 -30
- package/server.js +3 -2
- package/services/hls-session-manager.js +33 -1
- package/services/hwaccel.js +560 -514
- package/services/language-detect.js +73 -0
- package/services/subtitle-convert.js +150 -0
- package/services/torrent-pool.js +131 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2.9.30
|
|
2
|
+
|
|
3
|
+
- **New**: The proxy owns subtitle conversion and detects the language from content (OpenSpec change `subtitle-language`). `GET /api/subtitles` now also serves EXTERNAL subtitle files (no `trackIndex`): it reads the file, decodes its encoding (UTF-8 or Windows-1251 — common for Russian `.srt`), converts `.srt`/`.ass`/`.ssa` → WebVTT on the proxy (the browser no longer converts), and reports the language in `X-Subtitle-Language`/`X-Subtitle-Language-Name`. Language is detected with `franc` (n-gram, MIT) restricted to a curated language set — it distinguishes Russian from Ukrainian (and Latin languages) and avoids short-text false positives, returning no header when undetermined. Embedded tracks detect from the first chunk of extracted VTT. Pairs with the server release that fetches VTT from here and applies the filename → content → audio-language priority.
|
|
4
|
+
|
|
5
|
+
## 2.9.29
|
|
6
|
+
|
|
7
|
+
- **New**: Global disk cap with LRU eviction (OpenSpec change `disk-cap`; Disk hygiene Level 1, final piece). Downloaded torrent data was already removed on a 5-min idle TTL and at shutdown, but under pressure it could still fill a small Home Assistant host's disk (which can take down HA itself). The pool now caps total downloaded data — default min(10 GB, half of free disk), overridable with `--max-disk-bytes` (0 disables) — and, when exceeded, evicts whole torrents with no active reader least-recently-used first (checked every 30 s). A torrent that is currently playing is never evicted. (LRU = least-recently-used.)
|
|
8
|
+
- **New**: Output frame rate follows the source instead of a fixed 24 fps (OpenSpec change `transcode-quality`, part 1). 25/30 fps content no longer plays resampled to 24 (which caused judder). Frame-count-GOP encoders (software libx264, v4l2m2m) use an integer rate — source rounded, capped at 30 as a speed guard — with the fps filter and the GOP length kept in lockstep so a keyframe still lands on every segment boundary; the time-based-keyframe encoders (nvenc, vaapi, qsv) inherit the exact source rate untouched (nvenc previously forced 24 — its fps filter is removed). Source rate is parsed from the existing startup probe. (GOP = group of pictures, the span between keyframes; the segment grid needs a keyframe at each boundary.)
|
|
9
|
+
- **Fix**: `GET /api/sources/:key/files` no longer blocks until metadata arrives (or fails prematurely on a cold magnet). It now waits only a short per-request budget (`maxWaitMs`, default 8 s, cap 20 s) and returns `{ pending: true }` while the swarm fetch continues in the background, so the browser can poll — mirroring the cold-torrent playback-plan poll. Field-found: a magnet whose metadata had not arrived yet failed with "no peers" on the first paste, then succeeded on a second paste because the fetch had kept running in the background. A real fetch error now returns 502 (distinct from pending). Pairs with server 0.8.39 (which references this as "proxy 2.9.28" — that release was folded into 2.9.29 before publishing).
|
|
10
|
+
|
|
1
11
|
## 2.9.27
|
|
2
12
|
|
|
3
13
|
- **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.
|
package/bin/cli.js
CHANGED
|
@@ -56,6 +56,7 @@ program
|
|
|
56
56
|
.option("--name <name>", "Display name")
|
|
57
57
|
.option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
|
|
58
58
|
.option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
|
|
59
|
+
.option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
|
|
59
60
|
.option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
|
|
60
61
|
.option("--token <token>", "Registration token", "")
|
|
61
62
|
.addHelpText("after", HELP_EXAMPLES);
|
|
@@ -79,6 +80,12 @@ const clientName = options.name ? String(options.name) : `proxy-${clientId.slice
|
|
|
79
80
|
const token = String(options.token ?? "");
|
|
80
81
|
const transcodeAudio = options.transcodeAudio !== false;
|
|
81
82
|
const portMappingEnabled = options.portMapping !== false;
|
|
83
|
+
// Optional disk cap. undefined → the pool computes its own default; a valid
|
|
84
|
+
// non-negative number (0 disables) → passed through.
|
|
85
|
+
const maxDiskBytes =
|
|
86
|
+
options.maxDiskBytes !== undefined && Number.isFinite(Number(options.maxDiskBytes)) && Number(options.maxDiskBytes) >= 0
|
|
87
|
+
? Number(options.maxDiskBytes)
|
|
88
|
+
: undefined;
|
|
82
89
|
const bundledFfmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : "";
|
|
83
90
|
const ffmpegBin = options.ffmpegBin ? String(options.ffmpegBin) : bundledFfmpegBin || "ffmpeg";
|
|
84
91
|
|
|
@@ -234,7 +241,8 @@ try {
|
|
|
234
241
|
host: bindHost,
|
|
235
242
|
port: localPort,
|
|
236
243
|
transcodeAudio,
|
|
237
|
-
ffmpegBin
|
|
244
|
+
ffmpegBin,
|
|
245
|
+
maxDiskBytes
|
|
238
246
|
});
|
|
239
247
|
app = started.app;
|
|
240
248
|
actualPort = started.port;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Proposal: Global disk cap with LRU eviction (Disk hygiene Level 1, final)
|
|
2
|
+
|
|
3
|
+
## Why
|
|
4
|
+
|
|
5
|
+
Downloaded torrent data is already removed on a 300 s idle TTL and at
|
|
6
|
+
shutdown, and orphans are swept at startup — but under pressure (several
|
|
7
|
+
large files opened within the TTL window, or a fast fill) the total can
|
|
8
|
+
still grow unbounded and fill a small Home Assistant host's disk
|
|
9
|
+
(SD/eMMC on a Yellow/Pi is often 16–32 GB). A full disk can take down Home
|
|
10
|
+
Assistant itself. This adds the last missing Level 1 piece: a global cap.
|
|
11
|
+
|
|
12
|
+
## What Changes
|
|
13
|
+
|
|
14
|
+
- The pool tracks total downloaded bytes and, when it exceeds a cap, evicts
|
|
15
|
+
whole torrents with NO active reader, least-recently-used first, until
|
|
16
|
+
back under the cap (checked every 30 s and reused via the existing
|
|
17
|
+
remove-with-store path). A torrent that is currently playing is never
|
|
18
|
+
evicted — we cannot delete what is in use.
|
|
19
|
+
- The cap defaults to `min(10 GB, half of free disk)` (measured via
|
|
20
|
+
`statfs` on the store filesystem), and is overridable with
|
|
21
|
+
`--max-disk-bytes` (0 disables).
|
|
22
|
+
|
|
23
|
+
## Capabilities
|
|
24
|
+
|
|
25
|
+
### New Capabilities
|
|
26
|
+
|
|
27
|
+
- `disk-cap`: bounded total on-disk footprint via LRU eviction.
|
|
28
|
+
|
|
29
|
+
### Modified Capabilities
|
|
30
|
+
|
|
31
|
+
<!-- none -->
|
|
32
|
+
|
|
33
|
+
## Impact
|
|
34
|
+
|
|
35
|
+
- `services/torrent-pool.js` (access tracking, cap enforcement),
|
|
36
|
+
`bin/cli.js` (`--max-disk-bytes`), `server.js` (option pass-through);
|
|
37
|
+
ha-addon bump. Part of the proxy transcode/hygiene batch (2.9.29).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# disk-cap — delta spec
|
|
2
|
+
|
|
3
|
+
## ADDED Requirements
|
|
4
|
+
|
|
5
|
+
### Requirement: Total torrent data is bounded by a disk cap
|
|
6
|
+
The pool SHALL keep the total downloaded torrent footprint under a cap. When
|
|
7
|
+
the total exceeds the cap it SHALL evict whole torrents that have no active
|
|
8
|
+
file reader, least-recently-used first, removing each with its on-disk store,
|
|
9
|
+
until back under the cap or no evictable torrent remains. A torrent with an
|
|
10
|
+
active reader SHALL NEVER be evicted. The cap SHALL default to the smaller of
|
|
11
|
+
10 GB and half the free disk, and be overridable (0 disables).
|
|
12
|
+
|
|
13
|
+
#### Scenario: Idle torrents evicted under pressure
|
|
14
|
+
- **WHEN** the total downloaded data exceeds the cap and some torrents have no
|
|
15
|
+
active reader
|
|
16
|
+
- **THEN** the least-recently-used idle torrents are removed with their stores
|
|
17
|
+
until the total is back under the cap
|
|
18
|
+
|
|
19
|
+
#### Scenario: Active torrent protected
|
|
20
|
+
- **WHEN** the cap is exceeded but the only large torrent is currently playing
|
|
21
|
+
- **THEN** it is not evicted (the cap cannot delete in-use data)
|
|
22
|
+
|
|
23
|
+
#### Scenario: Cap disabled
|
|
24
|
+
- **WHEN** the cap is set to 0
|
|
25
|
+
- **THEN** no eviction occurs
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Tasks: Global disk cap with LRU eviction
|
|
2
|
+
|
|
3
|
+
## 1. Implementation
|
|
4
|
+
|
|
5
|
+
- [x] 1.1 Access tracking (`#lastAccess`) updated on getTorrent (incl. the
|
|
6
|
+
duplicate-infoHash path) and acquireFile
|
|
7
|
+
- [x] 1.2 Cap computed at construction (`min(10GB, half free)` via statfs) or
|
|
8
|
+
taken from the `maxDiskBytes` option; 0 disables
|
|
9
|
+
- [x] 1.3 Periodic (30 s) `#enforceDiskCap`: evict zero-reader torrents
|
|
10
|
+
LRU-first via the existing remove-with-store path; clear timer on
|
|
11
|
+
destroyAll; drop `#lastAccess` on removal
|
|
12
|
+
- [x] 1.4 `--max-disk-bytes` CLI flag → server.js → pool
|
|
13
|
+
- [x] 1.5 Syntax checks + unit-test the eviction ordering/active-skip
|
|
14
|
+
|
|
15
|
+
## 2. Release
|
|
16
|
+
|
|
17
|
+
- [ ] 2.1 Ship in the proxy batch (2.9.29) + ha-addon bump
|
|
18
|
+
- [ ] 2.2 Field-check on the host: open several large files, confirm idle
|
|
19
|
+
ones are evicted and playback is never interrupted
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Proposal: Proxy owns subtitle conversion + content-based language detection
|
|
2
|
+
|
|
3
|
+
## Why
|
|
4
|
+
|
|
5
|
+
Subtitle language was guessed only from the filename, so a file without a
|
|
6
|
+
language code (e.g. the Enola release's `.srt`) showed "Unknown", and the
|
|
7
|
+
owner specifically does not want Ukrainian and Russian confused. Reliable
|
|
8
|
+
detection needs an n-gram model on the actual text — which belongs on the
|
|
9
|
+
proxy (Node, node_modules, no per-browser payload, and the subtitle bytes are
|
|
10
|
+
right there) rather than in the browser.
|
|
11
|
+
|
|
12
|
+
## What Changes
|
|
13
|
+
|
|
14
|
+
- The proxy becomes the single owner of subtitle content: `GET /api/subtitles`
|
|
15
|
+
now also serves EXTERNAL subtitle files (no `trackIndex`) — it reads the
|
|
16
|
+
file, decodes its encoding (UTF-8 or Windows-1251, common for Russian
|
|
17
|
+
`.srt`), and converts `.srt`/`.ass`/`.ssa` → WebVTT here. The browser no
|
|
18
|
+
longer converts.
|
|
19
|
+
- The proxy detects the language from the full text with `franc` (n-gram /
|
|
20
|
+
trigram, MIT), restricted to a curated set of plausible subtitle languages
|
|
21
|
+
(distinguishes ru/uk/bg/sr and Latin languages; avoids short-text false
|
|
22
|
+
positives like English→Scots), and reports it in `X-Subtitle-Language`
|
|
23
|
+
(+ `X-Subtitle-Language-Name`). Embedded tracks detect from the first chunk
|
|
24
|
+
of extracted VTT.
|
|
25
|
+
- The browser sets each track's language by priority: explicit code in the
|
|
26
|
+
filename / container metadata (author intent) → proxy content detection →
|
|
27
|
+
the film's audio-track language (forced-signs subs usually match the dub)
|
|
28
|
+
→ Unknown.
|
|
29
|
+
|
|
30
|
+
## Capabilities
|
|
31
|
+
|
|
32
|
+
### New Capabilities
|
|
33
|
+
|
|
34
|
+
- `subtitle-language`: proxy-side subtitle conversion, encoding handling and
|
|
35
|
+
content-based language detection.
|
|
36
|
+
|
|
37
|
+
### Modified Capabilities
|
|
38
|
+
|
|
39
|
+
<!-- track-selection covered embedded extraction; this extends /api/subtitles
|
|
40
|
+
to external files and adds detection. Its change is unarchived, so this
|
|
41
|
+
lands as a new capability rather than a delta. -->
|
|
42
|
+
|
|
43
|
+
## Impact
|
|
44
|
+
|
|
45
|
+
- proxy: new `services/subtitle-convert.js`, `services/language-detect.js`
|
|
46
|
+
(franc dep), extended `routes/api/subtitles/get.js`.
|
|
47
|
+
- server: `components/loading/loading.js` fetches VTT from the proxy for
|
|
48
|
+
external subs (drops client-side conversion) and applies the language
|
|
49
|
+
priority; browser franc/alphabet detection removed.
|
|
50
|
+
- Pairs with a server release; requires the ha-addon bump.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# subtitle-language — delta spec
|
|
2
|
+
|
|
3
|
+
## ADDED Requirements
|
|
4
|
+
|
|
5
|
+
### Requirement: Proxy converts subtitles and reports the language
|
|
6
|
+
`GET /api/subtitles` SHALL serve every subtitle as WebVTT and report the
|
|
7
|
+
detected language in `X-Subtitle-Language` (ISO 639-1) and
|
|
8
|
+
`X-Subtitle-Language-Name`. It SHALL handle two modes: an embedded track
|
|
9
|
+
(`trackIndex` given — extracted via ffmpeg) and an external subtitle file (no
|
|
10
|
+
`trackIndex` — the file is read, its encoding decoded, and `.srt`/`.ass`/
|
|
11
|
+
`.ssa` converted to WebVTT on the proxy). A leading BOM SHALL be stripped and
|
|
12
|
+
Windows-1251 bytes decoded when the file is not valid UTF-8.
|
|
13
|
+
|
|
14
|
+
#### Scenario: External Russian .srt without a filename code
|
|
15
|
+
- **WHEN** an external `.srt` whose name has no language code is requested
|
|
16
|
+
- **THEN** the response is WebVTT and `X-Subtitle-Language` is `ru`
|
|
17
|
+
|
|
18
|
+
#### Scenario: Ukrainian is not reported as Russian
|
|
19
|
+
- **WHEN** the subtitle text is Ukrainian
|
|
20
|
+
- **THEN** the detected language is `uk`, not `ru`
|
|
21
|
+
|
|
22
|
+
#### Scenario: Unsupported format
|
|
23
|
+
- **WHEN** an image-based or unconvertible subtitle is requested
|
|
24
|
+
- **THEN** the proxy responds 422
|
|
25
|
+
|
|
26
|
+
### Requirement: Detection is confidence-gated
|
|
27
|
+
Language detection SHALL be restricted to a curated set of plausible subtitle
|
|
28
|
+
languages and SHALL return no language (omit the header) when the text is too
|
|
29
|
+
short or undetermined, rather than emitting a wrong guess.
|
|
30
|
+
|
|
31
|
+
#### Scenario: Too little text
|
|
32
|
+
- **WHEN** the subtitle has only a few characters
|
|
33
|
+
- **THEN** no language header is set (the browser falls back to filename or
|
|
34
|
+
audio language)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Tasks: Proxy subtitle conversion + language detection
|
|
2
|
+
|
|
3
|
+
## 1. Proxy
|
|
4
|
+
|
|
5
|
+
- [x] 1.1 `services/subtitle-convert.js`: encoding-aware decode (UTF-8/BOM/
|
|
6
|
+
Windows-1251) + srt/ass/ssa → WebVTT (ported, BOM-stripped)
|
|
7
|
+
- [x] 1.2 `services/language-detect.js`: franc restricted to a curated
|
|
8
|
+
ISO 639-3→639-1 allowlist; null when undetermined/too short
|
|
9
|
+
- [x] 1.3 `routes/api/subtitles/get.js`: external-file mode (read, decode,
|
|
10
|
+
convert, detect) + `X-Subtitle-Language(-Name)` on both modes
|
|
11
|
+
- [x] 1.4 franc dependency added to proxy
|
|
12
|
+
- [x] 1.5 Verified: unit-tested convert+detect (ru/uk/en/de, short→null) and
|
|
13
|
+
the route end-to-end on the real Enola .srt (→ ru, 19 cues)
|
|
14
|
+
|
|
15
|
+
## 2. Server (browser)
|
|
16
|
+
|
|
17
|
+
- [x] 2.1 External subs fetch VTT from `/api/subtitles` (no client convert)
|
|
18
|
+
- [x] 2.2 Language priority: filename code → X-Subtitle-Language → audio-track
|
|
19
|
+
language → und; embedded track uses metadata → header → audio
|
|
20
|
+
- [x] 2.3 Removed the client-side convertSubtitleToVtt call + dead import
|
|
21
|
+
|
|
22
|
+
## 3. Release
|
|
23
|
+
|
|
24
|
+
- [ ] 3.1 Proxy publish (OTP) + ha-addon bump + server patch
|
|
25
|
+
- [ ] 3.2 Field-check: Enola external .srt labels "Russian"; a Ukrainian sub
|
|
26
|
+
labels "Ukrainian"; embedded tracks keep their metadata language
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Proposal: Transcode quality — source fps, realtime budget, HDR, manual quality
|
|
2
|
+
|
|
3
|
+
## Why
|
|
4
|
+
|
|
5
|
+
The transcode pipeline was tuned for "make it play at all" and left several
|
|
6
|
+
quality/robustness gaps flagged in the project analysis: output was hard-
|
|
7
|
+
locked to 24 fps (25/30 fps content played with resampling judder); the
|
|
8
|
+
encoder profile could be slower than realtime on weak hosts (stalls instead
|
|
9
|
+
of graceful degradation); 10-bit/HDR sources transcoded to 8-bit H.264
|
|
10
|
+
without tone mapping (washed-out colours); and the viewer had no way to force
|
|
11
|
+
a resolution. This change is the transcode-stage batch (proxy side; the
|
|
12
|
+
manual-quality menu also needs the server UI).
|
|
13
|
+
|
|
14
|
+
## What Changes
|
|
15
|
+
|
|
16
|
+
- **Source fps** (DONE): the output frame rate is inherited from the source
|
|
17
|
+
(rounded to an integer, capped at 30), replacing the fixed 24. The
|
|
18
|
+
fixed-GOP encoders keep the fps↔GOP relationship exact so keyframes stay on
|
|
19
|
+
the segment grid; time-based-keyframe encoders (nvenc) just use it as the
|
|
20
|
+
rate; VAAPI/QSV already inherited.
|
|
21
|
+
- **Realtime budget** (planned): the startup benchmark picks the
|
|
22
|
+
encoder/preset/resolution/fps combination whose predicted throughput stays
|
|
23
|
+
above realtime; a source that would not encode in time is downscaled
|
|
24
|
+
(720/540p) rather than refused, and a sustained runtime `speed<1` triggers
|
|
25
|
+
a restart with a lighter profile. `-maxrate`/`-bufsize` cap bitrate spikes.
|
|
26
|
+
- **HDR tone mapping** (planned): detect 10-bit/HDR (pix_fmt,
|
|
27
|
+
color_transfer smpte2084/HLG) and insert a tone-map chain when re-encoding
|
|
28
|
+
to 8-bit H.264, so colours are not washed out. Depends on the ffmpeg build
|
|
29
|
+
having the tonemap filters.
|
|
30
|
+
- **Manual quality** (planned): a player Quality menu — Auto (current
|
|
31
|
+
viewport/DPR behaviour) plus forced resolutions — the proxy already
|
|
32
|
+
honours a requested target height.
|
|
33
|
+
|
|
34
|
+
## Capabilities
|
|
35
|
+
|
|
36
|
+
### New Capabilities
|
|
37
|
+
|
|
38
|
+
- `transcode-quality`: output frame rate, realtime encode budget, HDR tone
|
|
39
|
+
mapping, and explicit quality selection.
|
|
40
|
+
|
|
41
|
+
### Modified Capabilities
|
|
42
|
+
|
|
43
|
+
<!-- none -->
|
|
44
|
+
|
|
45
|
+
## Impact
|
|
46
|
+
|
|
47
|
+
- `services/hwaccel.js` (fps, benchmark, tonemap args),
|
|
48
|
+
`services/hls-session-manager.js` (fps probe, runtime speed watch),
|
|
49
|
+
`services/playback-planner.js` (HDR/fps in the plan);
|
|
50
|
+
server player UI for the Quality menu; ha-addon bump.
|
|
51
|
+
- Released as a batch (proxy 2.9.29 + addon) after the pieces land.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# transcode-quality — delta spec
|
|
2
|
+
|
|
3
|
+
## ADDED Requirements
|
|
4
|
+
|
|
5
|
+
### Requirement: Output frame rate follows the source
|
|
6
|
+
When re-encoding video, the proxy SHALL NOT force a fixed 24 fps. The output
|
|
7
|
+
rate SHALL follow the source, and the fps handling SHALL depend on how the
|
|
8
|
+
chosen encoder places keyframes:
|
|
9
|
+
|
|
10
|
+
- Frame-count-GOP encoders (software libx264, v4l2m2m) SHALL use an INTEGER
|
|
11
|
+
output rate — source rate rounded and capped — with the `fps` filter and
|
|
12
|
+
the GOP length (`segmentDuration × fps`) using that same integer, so a
|
|
13
|
+
keyframe lands on every segment boundary and segments do not drift off the
|
|
14
|
+
synthetic playlist's uniform grid. When the source rate is unknown they
|
|
15
|
+
SHALL fall back to the default rate.
|
|
16
|
+
- Time-based-keyframe encoders (nvenc, vaapi, qsv) SHALL inherit the exact
|
|
17
|
+
source rate with no fps filter (their keyframes are forced by output time,
|
|
18
|
+
so any rate segments correctly); no rounding, no cap.
|
|
19
|
+
|
|
20
|
+
#### Scenario: 25 fps source on the software encoder
|
|
21
|
+
- **WHEN** a 25 fps video is re-encoded with libx264 and 4-second segments
|
|
22
|
+
- **THEN** the output is 25 fps and the GOP is 100 frames (keyframe every
|
|
23
|
+
segment)
|
|
24
|
+
|
|
25
|
+
#### Scenario: High-fps source on the software encoder
|
|
26
|
+
- **WHEN** a 60 fps video is re-encoded with libx264/v4l2m2m
|
|
27
|
+
- **THEN** the output rate is capped at 30 fps (speed guard)
|
|
28
|
+
|
|
29
|
+
#### Scenario: Fractional source on a hardware time-based encoder
|
|
30
|
+
- **WHEN** a 23.976 fps video is re-encoded with nvenc/vaapi/qsv
|
|
31
|
+
- **THEN** the exact source rate is kept (no fps filter) and segments are
|
|
32
|
+
still cut on time
|
|
33
|
+
|
|
34
|
+
#### Scenario: Unknown source rate
|
|
35
|
+
- **WHEN** the source frame rate cannot be probed on the software path
|
|
36
|
+
- **THEN** the output falls back to the default rate and playback still
|
|
37
|
+
segments correctly
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Tasks: Transcode quality
|
|
2
|
+
|
|
3
|
+
## 1. Source fps
|
|
4
|
+
|
|
5
|
+
- [x] 1.1 hwaccel: `chooseOutputFps` (integer, capped); `buildVideoArgs`
|
|
6
|
+
takes `fps`, threaded into the filter + frame-count GOP (software,
|
|
7
|
+
v4l2m2m) and the filter only (nvenc); VAAPI/QSV unchanged (already
|
|
8
|
+
inherit); startup test-encode/benchmark keep the fixed rate
|
|
9
|
+
- [x] 1.2 hls-session-manager: parse source fps from the probe, compute
|
|
10
|
+
`session.outputFps`, pass it into `buildVideoArgs`
|
|
11
|
+
- [x] 1.3 Unit-verify fps choice and fps↔GOP consistency (25→100, 24→96,
|
|
12
|
+
default→96); syntax checks
|
|
13
|
+
|
|
14
|
+
## 2. Realtime budget (planned)
|
|
15
|
+
|
|
16
|
+
- [ ] 2.1 Benchmark picks encoder/preset/resolution/fps within a realtime
|
|
17
|
+
margin; downscale instead of refuse
|
|
18
|
+
- [ ] 2.2 Runtime `speed<1` watch → restart with a lighter profile
|
|
19
|
+
- [ ] 2.3 `-maxrate`/`-bufsize`
|
|
20
|
+
|
|
21
|
+
## 3. HDR tone mapping (planned)
|
|
22
|
+
|
|
23
|
+
- [ ] 3.1 Detect 10-bit/HDR; insert tonemap chain when re-encoding to 8-bit
|
|
24
|
+
- [ ] 3.2 Guard on tonemap-filter availability in the ffmpeg build
|
|
25
|
+
|
|
26
|
+
## 4. Manual quality (planned)
|
|
27
|
+
|
|
28
|
+
- [ ] 4.1 Proxy honours requested target height (already partly there)
|
|
29
|
+
- [ ] 4.2 Server Quality menu (Auto + forced resolutions)
|
|
30
|
+
|
|
31
|
+
## 5. Release
|
|
32
|
+
|
|
33
|
+
- [ ] 5.1 Batch release: proxy 2.9.29 + ha-addon bump; field-test on the
|
|
34
|
+
owner's hardware (25/30 fps content plays without judder; seek intact)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@torrent-tv/proxy",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.30",
|
|
4
4
|
"description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"publishConfig": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"commander": "^12.1.0",
|
|
27
27
|
"fastify": "^5.8.5",
|
|
28
28
|
"ffmpeg-static": "^5.3.0",
|
|
29
|
+
"franc": "^6.2.0",
|
|
29
30
|
"get-port": "^7.1.0",
|
|
30
31
|
"node-datachannel": "^0.32.0",
|
|
31
32
|
"webtorrent": "^2.8.4",
|
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
* GET /api/sources/:sourceKey/files
|
|
5
5
|
*
|
|
6
6
|
* The browser parses `.torrent` files locally, but a magnet URI carries no
|
|
7
|
-
* file list — the metadata comes from the swarm
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* file list — the metadata comes from the swarm and can take a while to
|
|
8
|
+
* arrive on a cold magnet. Rather than block the request until it does (a
|
|
9
|
+
* single long request racing the transport timeout, which surfaced as a
|
|
10
|
+
* premature "no peers" error while the metadata was in fact still arriving),
|
|
11
|
+
* this waits only a short per-request budget: if the metadata is not ready it
|
|
12
|
+
* returns `{ pending: true }` while the fetch continues in the background. The
|
|
13
|
+
* caller polls again until the file list comes back — mirroring the cold-
|
|
14
|
+
* torrent playback-plan poll.
|
|
10
15
|
*
|
|
11
16
|
* @param {import("fastify").FastifyRequest} req
|
|
12
17
|
* @param {import("fastify").FastifyReply} reply
|
|
@@ -16,6 +21,10 @@
|
|
|
16
21
|
* }} deps
|
|
17
22
|
* @returns {Promise<void>}
|
|
18
23
|
*/
|
|
24
|
+
|
|
25
|
+
/** Sentinel resolved when the per-request wait elapses before metadata. */
|
|
26
|
+
const PENDING = Symbol("pending");
|
|
27
|
+
|
|
19
28
|
export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
20
29
|
const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
21
30
|
if (!sourceKey) {
|
|
@@ -26,7 +35,41 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
|
|
|
26
35
|
return reply.code(404).send({ error: "Source key was not found." });
|
|
27
36
|
}
|
|
28
37
|
|
|
29
|
-
|
|
38
|
+
// How long to wait within THIS request before returning `pending`. Well
|
|
39
|
+
// under the transport's request timeout so a single poll never races it.
|
|
40
|
+
const rawWait = Number(req.query?.maxWaitMs);
|
|
41
|
+
const maxWaitMs = Number.isFinite(rawWait) && rawWait > 0 ? Math.min(rawWait, 20_000) : 8_000;
|
|
42
|
+
|
|
43
|
+
// getTorrent dedupes concurrent/repeated calls via the pool's in-flight map,
|
|
44
|
+
// so polling keeps joining the same background fetch (metadata keeps
|
|
45
|
+
// downloading between polls). Race it against the wait budget; if the wait
|
|
46
|
+
// wins, the fetch is left running for the next poll to observe.
|
|
47
|
+
let timer;
|
|
48
|
+
const waitPromise = new Promise((resolve) => {
|
|
49
|
+
timer = setTimeout(() => resolve(PENDING), maxWaitMs);
|
|
50
|
+
timer.unref?.();
|
|
51
|
+
});
|
|
52
|
+
const torrentPromise = torrentPool
|
|
53
|
+
.getTorrent(sourceRecord.sourceType, sourceRecord.source)
|
|
54
|
+
// Swallow so a rejection that loses the race is not an unhandled rejection;
|
|
55
|
+
// the next poll re-issues getTorrent and re-observes any real error.
|
|
56
|
+
.catch((error) => (error instanceof Error ? error : new Error(String(error))));
|
|
57
|
+
|
|
58
|
+
let result;
|
|
59
|
+
try {
|
|
60
|
+
result = await Promise.race([torrentPromise, waitPromise]);
|
|
61
|
+
} finally {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (result === PENDING) {
|
|
66
|
+
return reply.send({ pending: true });
|
|
67
|
+
}
|
|
68
|
+
if (result instanceof Error) {
|
|
69
|
+
return reply.code(502).send({ error: `Could not load torrent metadata: ${result.message}` });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const torrent = result;
|
|
30
73
|
const files = (torrent.files ?? []).map((file, index) => ({
|
|
31
74
|
index,
|
|
32
75
|
name: file?.name ?? "",
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Serve a subtitle as WebVTT, with the detected language reported in the
|
|
3
|
+
* `X-Subtitle-Language` / `X-Subtitle-Language-Name` response headers. Two
|
|
4
|
+
* modes:
|
|
3
5
|
*
|
|
4
|
-
*
|
|
6
|
+
* - Embedded track: ?sourceKey&fileIndex=<video>&trackIndex=<sub stream N>
|
|
7
|
+
* ffmpeg extracts the text subtitle stream (`-map 0:s:N -f webvtt`),
|
|
8
|
+
* streamed as it is produced.
|
|
9
|
+
* - External file: ?sourceKey&fileIndex=<subtitle file> (no trackIndex)
|
|
10
|
+
* the subtitle FILE is read, decoded (UTF-8/Windows-1251), and converted
|
|
11
|
+
* (.srt/.ass/.ssa → WebVTT) here on the proxy.
|
|
5
12
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
+
* The proxy owns subtitle conversion + language detection so no model or
|
|
14
|
+
* converter ships to the browser and detection sees the full text.
|
|
13
15
|
*
|
|
14
16
|
* @param {import("fastify").FastifyRequest} req
|
|
15
17
|
* @param {import("fastify").FastifyReply} reply
|
|
@@ -23,19 +25,35 @@
|
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
27
|
import { spawn } from "node:child_process";
|
|
28
|
+
import { convertSubtitleToVtt, decodeSubtitleBytes } from "../../../services/subtitle-convert.js";
|
|
29
|
+
import { detectLanguage } from "../../../services/language-detect.js";
|
|
26
30
|
|
|
27
|
-
// Safety cap: no extraction may outlive this
|
|
28
|
-
// hold the ffmpeg process forever).
|
|
31
|
+
// Safety cap: no embedded extraction may outlive this.
|
|
29
32
|
const EXTRACTION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
33
|
+
// External subtitle files are small; cap the read to guard against a bad index.
|
|
34
|
+
const EXTERNAL_MAX_BYTES = 8 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
/** Set the detected-language response headers (no-op when detection failed). */
|
|
37
|
+
function setLanguageHeaders(reply, lang) {
|
|
38
|
+
if (lang && typeof lang.code === "string") {
|
|
39
|
+
reply.raw.setHeader("X-Subtitle-Language", lang.code);
|
|
40
|
+
if (typeof lang.name === "string") {
|
|
41
|
+
reply.raw.setHeader("X-Subtitle-Language-Name", encodeURIComponent(lang.name));
|
|
42
|
+
}
|
|
43
|
+
// These are custom headers on a cross-origin fetch — expose them.
|
|
44
|
+
reply.raw.setHeader("Access-Control-Expose-Headers", "X-Subtitle-Language, X-Subtitle-Language-Name");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
30
47
|
|
|
31
48
|
export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torrentPool, ffmpegBin, localBaseUrl }) {
|
|
32
49
|
const query = req.query ?? {};
|
|
33
50
|
const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey.trim() : "";
|
|
34
51
|
const fileIndex = Number(query.fileIndex);
|
|
52
|
+
const hasTrackIndex = query.trackIndex !== undefined && query.trackIndex !== "";
|
|
35
53
|
const trackIndex = Number(query.trackIndex);
|
|
36
54
|
|
|
37
|
-
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0
|
|
38
|
-
return reply.code(400).send({ error: "sourceKey
|
|
55
|
+
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
56
|
+
return reply.code(400).send({ error: "sourceKey and fileIndex are required." });
|
|
39
57
|
}
|
|
40
58
|
|
|
41
59
|
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
@@ -43,28 +61,47 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
43
61
|
return reply.code(404).send({ error: "Source key was not found." });
|
|
44
62
|
}
|
|
45
63
|
const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
46
|
-
|
|
64
|
+
const file = torrent.files[fileIndex];
|
|
65
|
+
if (!file) {
|
|
47
66
|
return reply.code(404).send({ error: "File index was not found in torrent." });
|
|
48
67
|
}
|
|
49
68
|
|
|
69
|
+
// ---- External subtitle FILE (no trackIndex) -----------------------------
|
|
70
|
+
if (!hasTrackIndex) {
|
|
71
|
+
const name = typeof file.name === "string" ? file.name : "";
|
|
72
|
+
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
73
|
+
const release = torrentPool.acquireFile(torrent, fileIndex);
|
|
74
|
+
try {
|
|
75
|
+
const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
|
|
76
|
+
const text = decodeSubtitleBytes(bytes);
|
|
77
|
+
const vtt = convertSubtitleToVtt(text, ext);
|
|
78
|
+
if (!vtt) {
|
|
79
|
+
return reply.code(422).send({ error: `Unsupported subtitle format: ${ext}` });
|
|
80
|
+
}
|
|
81
|
+
setLanguageHeaders(reply, detectLanguage(text));
|
|
82
|
+
reply.header("content-type", "text/vtt; charset=utf-8");
|
|
83
|
+
reply.header("cache-control", "no-store");
|
|
84
|
+
return reply.send(vtt);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
87
|
+
return reply.code(502).send({ error: `Could not read subtitle file: ${message}` });
|
|
88
|
+
} finally {
|
|
89
|
+
release();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---- Embedded track (ffmpeg extraction, streamed) -----------------------
|
|
94
|
+
if (!Number.isInteger(trackIndex) || trackIndex < 0) {
|
|
95
|
+
return reply.code(400).send({ error: "trackIndex must be a non-negative integer." });
|
|
96
|
+
}
|
|
97
|
+
|
|
50
98
|
const inputUrl = new URL("/stream", `${localBaseUrl}/`);
|
|
51
99
|
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
52
100
|
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
53
101
|
|
|
54
102
|
const ffmpeg = spawn(
|
|
55
103
|
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
|
-
],
|
|
104
|
+
["-hide_banner", "-loglevel", "error", "-i", inputUrl.toString(), "-map", `0:s:${trackIndex}`, "-f", "webvtt", "pipe:1"],
|
|
68
105
|
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true }
|
|
69
106
|
);
|
|
70
107
|
|
|
@@ -81,8 +118,6 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
81
118
|
}
|
|
82
119
|
}, EXTRACTION_TIMEOUT_MS);
|
|
83
120
|
killTimer.unref?.();
|
|
84
|
-
|
|
85
|
-
// Stop extracting when the client goes away.
|
|
86
121
|
req.raw.on("close", () => {
|
|
87
122
|
clearTimeout(killTimer);
|
|
88
123
|
if (!ffmpeg.killed) {
|
|
@@ -90,8 +125,6 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
90
125
|
}
|
|
91
126
|
});
|
|
92
127
|
|
|
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
128
|
const firstChunk = await new Promise((resolve) => {
|
|
96
129
|
let settled = false;
|
|
97
130
|
const settle = (value) => {
|
|
@@ -112,6 +145,9 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
112
145
|
.send({ error: `Subtitle track could not be extracted: ${stderr.trim() || "no output from ffmpeg"}` });
|
|
113
146
|
}
|
|
114
147
|
|
|
148
|
+
// Detect language from the first chunk of the produced VTT (embedded tracks
|
|
149
|
+
// frequently lack a language tag in their container metadata).
|
|
150
|
+
setLanguageHeaders(reply, detectLanguage(String(firstChunk)));
|
|
115
151
|
reply.raw.writeHead(200, {
|
|
116
152
|
"content-type": "text/vtt; charset=utf-8",
|
|
117
153
|
"cache-control": "no-store",
|
|
@@ -127,3 +163,29 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
127
163
|
reply.raw.end();
|
|
128
164
|
return reply;
|
|
129
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Read a torrent file fully into a Buffer, bounded by `maxBytes`.
|
|
169
|
+
*
|
|
170
|
+
* @param {{ createReadStream: () => import("node:stream").Readable, length?: number }} file
|
|
171
|
+
* @param {number} maxBytes
|
|
172
|
+
* @returns {Promise<Buffer>}
|
|
173
|
+
*/
|
|
174
|
+
function readFileFully(file, maxBytes) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const stream = file.createReadStream();
|
|
177
|
+
const chunks = [];
|
|
178
|
+
let total = 0;
|
|
179
|
+
stream.on("data", (chunk) => {
|
|
180
|
+
total += chunk.length;
|
|
181
|
+
if (total > maxBytes) {
|
|
182
|
+
stream.destroy();
|
|
183
|
+
reject(new Error("subtitle file exceeds the size cap"));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
chunks.push(chunk);
|
|
187
|
+
});
|
|
188
|
+
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
189
|
+
stream.on("error", reject);
|
|
190
|
+
});
|
|
191
|
+
}
|