@torrent-tv/proxy 2.63.0 → 2.64.1
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/CLAUDE.md +17 -0
- package/docs/container-architecture.md +86 -0
- package/package.json +1 -1
- package/routes/api/playback-plan/post.js +5 -6
- package/routes/api/subtitles/get.js +39 -208
- package/services/container/AviContainer.js +45 -0
- package/services/container/Container.js +59 -0
- package/services/container/ContainerFactory.js +31 -0
- package/services/container/MatroskaContainer.js +289 -0
- package/services/container/Mp4Container.js +242 -0
- package/services/container/index.js +5 -0
- package/services/controllers/PlaybackController.js +33 -0
- package/services/controllers/SubtitleController.js +126 -0
- package/services/controllers/index.js +2 -0
- package/services/orchestrators/ContainerOrchestrator.js +89 -0
- package/services/orchestrators/SubtitleOrchestrator.js +101 -0
- package/services/orchestrators/index.js +2 -0
- package/services/piece-store/shared-piece-store.js +5 -1
- package/services/tracks/AudioTrack.js +40 -0
- package/services/tracks/ContainerTrack.js +72 -0
- package/services/tracks/ExternalSubtitleFile.js +27 -0
- package/services/tracks/ImageSubtitleTrack.js +19 -0
- package/services/tracks/SubtitleTrack.js +38 -0
- package/services/tracks/TextSubtitleTrack.js +29 -0
- package/services/tracks/VideoTrack.js +33 -0
- package/services/tracks/index.js +7 -0
- package/test/worker-source-race.test.js +0 -76
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.64.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: A reader sizes its window from the memory the store may hold NOW, not from the allowance it was created with. 2.63.0 made the allowance follow the machine but left the `capacity` getter answering the original reservation, and that getter is what `ceilingPieces` reads — so a reader would have gone on claiming pieces against an allowance the machine had already withdrawn.
|
|
4
|
+
- **New**: Container and track domain — `Container` (RFC 9559 / ISO 14496-12) with `MatroskaContainer` / `Mp4Container` / `AviContainer` via `ContainerFactory` (16-byte sniff), and `ContainerTrack` → `VideoTrack` / `AudioTrack` (`FlagOriginal`/`FlagCommentary`/`FlagVisualImpaired`) / `SubtitleTrack` → `TextSubtitleTrack` (`S_TEXT/UTF8`, `tx3g`, `wvtt`) / `ImageSubtitleTrack` (`PGS`, `VobSub`, `subp`) + `ExternalSubtitleFile`. Every class constructed from its spec section: `FlagForced` only on subtitles (RFC 9559 §5.1.4.1 0x55AA), `FlagEnabled`/`FlagDefault` + `LanguageBCP47` MUST on base, `track_enabled`/`alternate_group`/`elng` on MP4, `AVIIF_KEYFRAME` on AVI. `LanguageBCP47` overrides `Language` per MUST, disabled tracks kept for `declaredIndex` alignment with ffmpeg `0:s:N` (roadmap 62).
|
|
5
|
+
- **New**: Application layer — `ContainerOrchestrator` (per-file cache, `getTracks`/`getKeyframeIndex`) and `SubtitleOrchestrator` (wraps `torrent-worker/subtitle-cues.js` cluster walk behind track abstraction).
|
|
6
|
+
- **New**: Interface layer — `PlaybackController` / `SubtitleController`; `routes/api/playback-plan/post.js` and `routes/api/subtitles/get.js` delegate to controllers instead of calling services directly.
|
|
7
|
+
- **Chore**: `proxy/docs/container-architecture.md` with mermaid class/sequence diagrams and flags matrix; `proxy/CLAUDE.md` layout updated; `services/container-index/` marked as internal detail used by `container/*`.
|
|
8
|
+
|
|
1
9
|
## 2.63.0
|
|
2
10
|
|
|
3
11
|
- **New**: The piece store says what it has TAKEN, not only what it holds. `committed` and `on-disk` stand beside `resident` in its line. The two are different quantities and the difference is the growth that had no explanation: the pool only ever grows — `SharedArrayBuffer` has no shrink — so a piece spilled to disk returns its slot to the free list and its memory to nobody. On 2026-08-28 the store reported "144MB" while the process held 893 MB.
|
package/CLAUDE.md
CHANGED
|
@@ -25,6 +25,23 @@ Linux-only host (e.g. POSIX-only signals must degrade elsewhere).
|
|
|
25
25
|
long-polls while a segment is being produced, returns retryable 503 (never
|
|
26
26
|
202 — hls.js can't consume it).
|
|
27
27
|
- `services/`:
|
|
28
|
+
- `container/` — domain layer: `Container` (abstract, RFC 9559 / ISO 14496-12),
|
|
29
|
+
`MatroskaContainer` / `Mp4Container` / `AviContainer`, `ContainerFactory`
|
|
30
|
+
(sniff 16 bytes → precise subclass). See `docs/container-architecture.md`.
|
|
31
|
+
- `tracks/` — domain layer: `ContainerTrack` (base: TrackNumber, declaredIndex,
|
|
32
|
+
language/BCP47, isEnabled/isDefault) → `VideoTrack` / `AudioTrack` /
|
|
33
|
+
`SubtitleTrack` → `TextSubtitleTrack` / `ImageSubtitleTrack`,
|
|
34
|
+
`ExternalSubtitleFile`. Spec-accurate flags (FlagForced only on subtitles per
|
|
35
|
+
RFC 9559 §5.1.4.1, FlagOriginal/Commentary only on audio, tkhd
|
|
36
|
+
track_enabled / alternate_group, elng BCP47).
|
|
37
|
+
- `orchestrators/` — application layer: `ContainerOrchestrator` (detect + per-file
|
|
38
|
+
cache, `getTracks`/`getKeyframeIndex`), `SubtitleOrchestrator` (wraps
|
|
39
|
+
`torrent-worker/subtitle-cues.js` cluster walk + `Container` tracks, warm/push).
|
|
40
|
+
- `controllers/` — interface layer: `PlaybackController` / `SubtitleController`
|
|
41
|
+
(thin adapters over orchestrators; routes depend on controllers, not services).
|
|
42
|
+
`routes/*` are now thin HTTP translators.
|
|
43
|
+
- `container-index/` — legacy readers (ebml-reader, matroska/mp4/avi keyframe and
|
|
44
|
+
subtitle tables) — used internally by `container/*`, deprecated as direct import.
|
|
28
45
|
- `playback-planner.js` — single ffmpeg probe returns audioCodec, videoCodec,
|
|
29
46
|
container, durationSeconds. `mode` is advisory; the browser decides.
|
|
30
47
|
- `hls-session-manager.js` — one ffmpeg per (source, file, settings). Serves a
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Container & Track architecture
|
|
2
|
+
|
|
3
|
+
Domain → Application → Interface, per RFC 9559 (Matroska) and ISO/IEC 14496-12 (MP4).
|
|
4
|
+
|
|
5
|
+
## Layers
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
flowchart TB
|
|
9
|
+
subgraph Domain
|
|
10
|
+
C[Container<br/>abstract<br/>RFC9559 / 14496-12]
|
|
11
|
+
MC[MatroskaContainer]
|
|
12
|
+
MpC[Mp4Container]
|
|
13
|
+
AC[AviContainer]
|
|
14
|
+
CT[ContainerTrack<br/>isEnabled/isDefault/language]
|
|
15
|
+
VT[VideoTrack]
|
|
16
|
+
AT[AudioTrack]
|
|
17
|
+
ST[SubtitleTrack]
|
|
18
|
+
TST[TextSubtitleTrack<br/>S_TEXT/UTF8 tx3g wvtt]
|
|
19
|
+
IST[ImageSubtitleTrack<br/>PGS VobSub subp]
|
|
20
|
+
C --> MC & MpC & AC
|
|
21
|
+
CT --> VT & AT & ST
|
|
22
|
+
ST --> TST & IST
|
|
23
|
+
MC & MpC & AC -- readTracks --> CT
|
|
24
|
+
end
|
|
25
|
+
subgraph Application
|
|
26
|
+
CF[ContainerFactory<br/>detect 16 bytes]
|
|
27
|
+
CO[ContainerOrchestrator<br/>cache + getTracks/getKeyframeIndex]
|
|
28
|
+
SO[SubtitleOrchestrator<br/>wrap subtitle-cues.js]
|
|
29
|
+
CF --> CO
|
|
30
|
+
CO --> SO
|
|
31
|
+
end
|
|
32
|
+
subgraph Interface
|
|
33
|
+
PC[PlaybackController]
|
|
34
|
+
SC[SubtitleController]
|
|
35
|
+
R1[routes/api/playback-plan]
|
|
36
|
+
R2[routes/api/subtitles]
|
|
37
|
+
PC --> R1
|
|
38
|
+
SC --> R2
|
|
39
|
+
CO --> PC
|
|
40
|
+
SO --> SC
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Class responsibilities (spec-grounded)
|
|
45
|
+
|
|
46
|
+
| Class | Spec section | Fields | Not responsible |
|
|
47
|
+
|---|---|---|---|
|
|
48
|
+
| `ContainerTrack` | RFC9559 TrackEntry common + ISO 14496-12 tkhd/mdhd/hdlr/elng | `trackNumber`, `declaredIndex`, `codecId`, `language`/`languageBcp47` (MUST rule), `name`, `isEnabled` (0xB9 / track_enabled), `isDefault`+`declaresDefault` (0x88) | Type-specific flags |
|
|
49
|
+
| `VideoTrack` | RFC9559 Video, ISO 14496-12 tkhd width/height, stsd | `width/height/display*`, `fps`, `isHdr`, `bitDepth` | Subtitle flags |
|
|
50
|
+
| `AudioTrack` | RFC9559 FlagOriginal 0x55AE, FlagCommentary 0x55AF, FlagVisualImpaired 0x55AC | `isOriginal/isCommentary/isVisualImpaired`, `channels/samplingFrequency` | FlagForced |
|
|
51
|
+
| `SubtitleTrack` | RFC9559 FlagForced 0x55AA (subtitle-only), FlagHearingImpaired 0x55AB | `isForced/isHearingImpaired`, `clusterPositions`/`samples` | Video dims |
|
|
52
|
+
| `TextSubtitleTrack` | `S_TEXT/UTF8, S_TEXT/ASS, tx3g, wvtt` | `toVtt()` convertible | Image tracks |
|
|
53
|
+
| `ImageSubtitleTrack` | `S_HDMV/PGS, S_VOBSUB, subp, clcp` | kept for `declaredIndex` alignment | Conversion |
|
|
54
|
+
| `MatroskaContainer` | RFC9559 SeekHead, Tracks, Cues, Clusters | single Tracks walk for all types, EBML via `ebml-reader.js` | HTTP |
|
|
55
|
+
| `Mp4Container` | ISO 14496-12 moov/trak/tkhd/mdhd/hdlr/elng/stbl | `alternate_group` grouping, packed language, `tx3g` forced bits | Torrent |
|
|
56
|
+
| `AviContainer` | RIFF AVI idx1 | `AVIIF_KEYFRAME` keyframe times | Tracks beyond video |
|
|
57
|
+
|
|
58
|
+
`VideoTrack` never carries `isForced` — spec states FlagForced "Applies only to subtitles". Placing it in base would pollute video with irrelevant state.
|
|
59
|
+
|
|
60
|
+
## Orchestrators & Controllers
|
|
61
|
+
|
|
62
|
+
- `ContainerFactory.create({readRange,fileSize})` — sniffs 16 bytes, returns precise `Container` subclass. No torrent knowledge.
|
|
63
|
+
- `ContainerOrchestrator` — per-file cache (`sourceKey:fileIndex`), `getTracks()` / `getKeyframeIndex()`. Transport-agnostic.
|
|
64
|
+
- `SubtitleOrchestrator` — wraps `torrent-worker/subtitle-cues.js` (`planFor`, `cuesHeldFor`, `warmSubtitleCues`) behind `ContainerTrack` abstraction. Routes depend on this, not on worker directly.
|
|
65
|
+
- `PlaybackController` / `SubtitleController` — thin interface adapters; `routes/api/*` delegate to them, handle HTTP headers (`X-Subtitle-Language`, `X-Subtitle-Cursor`) only.
|
|
66
|
+
|
|
67
|
+
## Legacy
|
|
68
|
+
|
|
69
|
+
`services/container-index/` remains as internal detail used by `container/*`. Direct imports from routes are deprecated — use `orchestrators/` and `controllers/` instead.
|
|
70
|
+
|
|
71
|
+
## Flags matrix
|
|
72
|
+
|
|
73
|
+
| Flag | Matroska ID | Applies to | Base or subclass |
|
|
74
|
+
|---|---|---|---|
|
|
75
|
+
| `FlagEnabled` | 0xB9 default 1 | all | `ContainerTrack` |
|
|
76
|
+
| `FlagDefault` | 0x88 default 1 | all | `ContainerTrack` (`declaresDefault`) |
|
|
77
|
+
| `Language` | 0x22B59C | all | `ContainerTrack` |
|
|
78
|
+
| `LanguageBCP47` | 0x22B59D MUST | all | `ContainerTrack` |
|
|
79
|
+
| `FlagForced` | 0x55AA | subtitle only | `SubtitleTrack` |
|
|
80
|
+
| `FlagHearingImpaired` | 0x55AB | subtitle | `SubtitleTrack` |
|
|
81
|
+
| `FlagVisualImpaired` | 0x55AC | audio (descriptive) + subtitle | `AudioTrack`/`SubtitleTrack` |
|
|
82
|
+
| `FlagOriginal` | 0x55AE | audio | `AudioTrack` |
|
|
83
|
+
| `FlagCommentary` | 0x55AF | audio | `AudioTrack` |
|
|
84
|
+
| `track_enabled` | tkhd 0x000001 | all | `ContainerTrack` |
|
|
85
|
+
| `alternate_group` | tkhd | audio/video alternates | `ContainerTrack.alternateGroup` |
|
|
86
|
+
| `elng` | 14496-12 §8.4.6 | all | `ContainerTrack.languageBcp47` |
|
package/package.json
CHANGED
|
@@ -24,7 +24,7 @@ function getPayload(body) {
|
|
|
24
24
|
return {};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner }) {
|
|
27
|
+
export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, sourceRegistry, torrentPool, ffmpegBin, localBaseUrl }) {
|
|
28
28
|
const payload = getPayload(req.body);
|
|
29
29
|
const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
|
|
30
30
|
const fileIndex = Number(payload.fileIndex);
|
|
@@ -34,12 +34,11 @@ export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
|
|
|
34
34
|
return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// Interface delegates to PlaybackController (orchestrator + domain). Keeps route thin.
|
|
38
|
+
const { PlaybackController } = await import("../../../services/controllers/PlaybackController.js");
|
|
39
|
+
const controller = new PlaybackController({ torrentPool, sourceRegistry, ffmpegBin, localBaseUrl, playbackPlanner });
|
|
37
40
|
try {
|
|
38
|
-
|
|
39
|
-
// planner returns quickly with `pending: true` instead of blocking up to
|
|
40
|
-
// the transport's 60 s request timeout. The browser polls again (the header
|
|
41
|
-
// keeps downloading, prioritised on each call). Well under that 60 s limit.
|
|
42
|
-
const plan = await playbackPlanner.getPlan({ sourceKey, fileIndex, userAgent, maxWaitMs: 8_000 });
|
|
41
|
+
const plan = await controller.getPlan({ sourceKey, fileIndex, userAgent, maxWaitMs: 8_000 });
|
|
43
42
|
return reply.send(plan);
|
|
44
43
|
} catch (error) {
|
|
45
44
|
if (error instanceof Error && error.code === "SOURCE_NOT_FOUND") {
|
|
@@ -25,15 +25,12 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { spawn } from "node:child_process";
|
|
28
|
-
import { convertSubtitleToVtt, decodeSubtitleBytes } from "../../../services/subtitle-convert.js";
|
|
29
28
|
import { detectLanguage } from "../../../services/language-detect.js";
|
|
30
|
-
import {
|
|
29
|
+
import { SubtitleController } from "../../../services/controllers/SubtitleController.js";
|
|
31
30
|
import { logger } from "../../../utils/logger.js";
|
|
32
31
|
|
|
33
32
|
// Safety cap: no embedded extraction may outlive this.
|
|
34
33
|
const EXTRACTION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
35
|
-
// External subtitle files are small; cap the read to guard against a bad index.
|
|
36
|
-
const EXTERNAL_MAX_BYTES = 8 * 1024 * 1024;
|
|
37
34
|
|
|
38
35
|
/** Set the detected-language response headers (no-op when detection failed). */
|
|
39
36
|
function setLanguageHeaders(reply, lang) {
|
|
@@ -58,92 +55,54 @@ export async function handleApiSubtitlesGet(req, reply, { sourceRegistry, torren
|
|
|
58
55
|
return reply.code(400).send({ error: "sourceKey and fileIndex are required." });
|
|
59
56
|
}
|
|
60
57
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
58
|
+
// Interface layer delegates to SubtitleController (orchestrator + domain),
|
|
59
|
+
// which owns external-file vs embedded-track branching and the cluster walk.
|
|
60
|
+
const controller = new SubtitleController({ sourceRegistry, torrentPool });
|
|
61
|
+
const since = Number.parseInt(String(req.query?.since ?? ""), 10);
|
|
62
|
+
const after = Number.parseFloat(String(req.query?.after ?? ""));
|
|
63
|
+
const result = await controller.getSubtitle({
|
|
64
|
+
sourceKey,
|
|
65
|
+
fileIndex,
|
|
66
|
+
trackIndex: hasTrackIndex ? trackIndex : undefined,
|
|
67
|
+
since: Number.isInteger(since) ? since : null,
|
|
68
|
+
after: Number.isFinite(after) ? after : null
|
|
69
|
+
});
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
const vtt = convertSubtitleToVtt(text, ext);
|
|
80
|
-
if (!vtt) {
|
|
81
|
-
return reply.code(422).send({ error: `Unsupported subtitle format: ${ext}` });
|
|
82
|
-
}
|
|
83
|
-
setLanguageHeaders(reply, detectLanguage(text));
|
|
71
|
+
if (result.error) {
|
|
72
|
+
return reply.code(result.status ?? 400).send({ error: result.error });
|
|
73
|
+
}
|
|
74
|
+
if (result.vtt !== undefined) {
|
|
75
|
+
if (result.vtt !== null) {
|
|
76
|
+
// External file or cluster-held cues — controller already detected language.
|
|
77
|
+
const lang = result.language ?? null;
|
|
78
|
+
if (lang) setLanguageHeaders(reply, lang);
|
|
84
79
|
reply.header("content-type", "text/vtt; charset=utf-8");
|
|
85
80
|
reply.header("cache-control", "no-store");
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
81
|
+
if (hasTrackIndex) {
|
|
82
|
+
reply.header("access-control-allow-origin", "*");
|
|
83
|
+
if (result.headers) {
|
|
84
|
+
for (const [k, v] of Object.entries(result.headers)) reply.header(k, String(v));
|
|
85
|
+
reply.raw.setHeader(
|
|
86
|
+
"Access-Control-Expose-Headers",
|
|
87
|
+
"X-Subtitle-Language, X-Subtitle-Language-Name, X-Subtitle-Covered-Clusters, X-Subtitle-Indexed-Clusters, X-Subtitle-Cursor"
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return reply.send(result.vtt);
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
// If controller returned pending, fall through to ffmpeg extraction below.
|
|
94
95
|
|
|
95
|
-
// ---- Embedded track
|
|
96
|
+
// ---- Embedded track — controller had no held cues, try cluster path directly for headers compatibility
|
|
97
|
+
// The controller's getSubtitle already attempted the cluster walk; reaching here means it returned pending.
|
|
98
|
+
if (!hasTrackIndex) {
|
|
99
|
+
// Should have been handled above — pending for external is unsupported format
|
|
100
|
+
return reply.code(422).send({ error: "Unsupported subtitle format" });
|
|
101
|
+
}
|
|
96
102
|
if (!Number.isInteger(trackIndex) || trackIndex < 0) {
|
|
97
103
|
return reply.code(400).send({ error: "trackIndex must be a non-negative integer." });
|
|
98
104
|
}
|
|
99
105
|
|
|
100
|
-
// From the clusters the viewer has already downloaded, if this file can be
|
|
101
|
-
// read that way. Costs no network at all and answers with the part of the
|
|
102
|
-
// film they are watching; the rest arrives as they watch it. Only when the
|
|
103
|
-
// container cannot be read this way does the old extraction run.
|
|
104
|
-
// How many cues this browser has already been sent, counted in the order
|
|
105
|
-
// they were FOUND. Sending them again is bytes for nothing: measured
|
|
106
|
-
// 2026-08-19, one track is 76 KB and the browser asked for it every few
|
|
107
|
-
// seconds while the film downloaded. Absent or unparsable means "send
|
|
108
|
-
// everything", which is what a browser asking for the first time wants.
|
|
109
|
-
//
|
|
110
|
-
// Found-order, not time. A cue's time cannot serve as a cursor here: the
|
|
111
|
-
// cues are read out of whichever clusters are downloaded, and those are not
|
|
112
|
-
// contiguous, so the set grows in the middle as well as at the end. The old
|
|
113
|
-
// `?after=<seconds>` therefore threw away every cue that turned up BEHIND
|
|
114
|
-
// the furthest one already sent — which is the stretch the viewer is about
|
|
115
|
-
// to watch. Measured 2026-08-20: a viewer at 272 s was sent cues out to
|
|
116
|
-
// 1176 s, and from that moment nothing between the two could ever reach
|
|
117
|
-
// them, with 59 of 276 clusters read. `after` is still honoured so an older
|
|
118
|
-
// browser keeps working.
|
|
119
|
-
const since = Number.parseInt(String(req.query?.since ?? ""), 10);
|
|
120
|
-
const after = Number.parseFloat(String(req.query?.after ?? ""));
|
|
121
|
-
const held = await cuesFromDownloadedClusters(
|
|
122
|
-
torrentPool,
|
|
123
|
-
torrent,
|
|
124
|
-
fileIndex,
|
|
125
|
-
trackIndex,
|
|
126
|
-
Number.isFinite(after) ? after : null,
|
|
127
|
-
Number.isInteger(since) ? since : null
|
|
128
|
-
);
|
|
129
|
-
if (held !== null) {
|
|
130
|
-
setLanguageHeaders(reply, held.language);
|
|
131
|
-
reply.header("content-type", "text/vtt; charset=utf-8");
|
|
132
|
-
reply.header("cache-control", "no-store");
|
|
133
|
-
reply.header("access-control-allow-origin", "*");
|
|
134
|
-
// How much of the film these cues cover, so the browser knows to ask again
|
|
135
|
-
// as playback moves into clusters that were not downloaded yet.
|
|
136
|
-
reply.header("X-Subtitle-Covered-Clusters", String(held.coveredClusters));
|
|
137
|
-
reply.header("X-Subtitle-Indexed-Clusters", String(held.indexedClusters));
|
|
138
|
-
// What to send back as `?since=` next time.
|
|
139
|
-
reply.header("X-Subtitle-Cursor", String(held.cursor));
|
|
140
|
-
reply.raw.setHeader(
|
|
141
|
-
"Access-Control-Expose-Headers",
|
|
142
|
-
"X-Subtitle-Language, X-Subtitle-Language-Name, X-Subtitle-Covered-Clusters, X-Subtitle-Indexed-Clusters, X-Subtitle-Cursor"
|
|
143
|
-
);
|
|
144
|
-
return reply.send(held.vtt);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
106
|
const inputUrl = new URL("/stream", `${localBaseUrl}/`);
|
|
148
107
|
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
149
108
|
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
@@ -238,132 +197,4 @@ function startExtraction({ key, ffmpegBin, localBaseUrl, sourceKey, fileIndex, t
|
|
|
238
197
|
ffmpeg.once("error", settle);
|
|
239
198
|
}
|
|
240
199
|
|
|
241
|
-
/**
|
|
242
|
-
* Read a torrent file fully into a Buffer, bounded by `maxBytes`.
|
|
243
|
-
*
|
|
244
|
-
* @param {{ createReadStream: () => import("node:stream").Readable, length?: number }} file
|
|
245
|
-
* @param {number} maxBytes
|
|
246
|
-
* @returns {Promise<Buffer>}
|
|
247
|
-
*/
|
|
248
|
-
function readFileFully(file, maxBytes) {
|
|
249
|
-
return new Promise((resolve, reject) => {
|
|
250
|
-
const stream = file.createReadStream();
|
|
251
|
-
const chunks = [];
|
|
252
|
-
let total = 0;
|
|
253
|
-
stream.on("data", (chunk) => {
|
|
254
|
-
total += chunk.length;
|
|
255
|
-
if (total > maxBytes) {
|
|
256
|
-
stream.destroy();
|
|
257
|
-
reject(new Error("subtitle file exceeds the size cap"));
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
chunks.push(chunk);
|
|
261
|
-
});
|
|
262
|
-
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
263
|
-
stream.on("error", reject);
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* The cues of a track from clusters already downloaded, as WebVTT.
|
|
269
|
-
*
|
|
270
|
-
* `trackIndex` is the browser's number for the subtitle stream — its position
|
|
271
|
-
* among ALL the subtitle streams, as ffmpeg lists them — while Matroska blocks
|
|
272
|
-
* carry the file's own track number. The plan lists only the tracks that can
|
|
273
|
-
* become text, so it is matched on `declaredIndex`, which each track carries
|
|
274
|
-
* for exactly this: its place in the file's full list of subtitle tracks.
|
|
275
|
-
*
|
|
276
|
-
* @param {object} torrentPool
|
|
277
|
-
* @param {object} torrent
|
|
278
|
-
* @param {number} fileIndex
|
|
279
|
-
* @param {number} trackIndex
|
|
280
|
-
* @returns {Promise<{ vtt: string, language: object | null, coveredClusters: number, indexedClusters: number } | null>}
|
|
281
|
-
* Null when this file cannot be read this way, and then the caller falls back.
|
|
282
|
-
*/
|
|
283
|
-
async function cuesFromDownloadedClusters(torrentPool, torrent, fileIndex, trackIndex, after = null, since = null) {
|
|
284
|
-
if (typeof torrentPool?.getSubtitleTracks !== "function") {
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
let tracks;
|
|
288
|
-
try {
|
|
289
|
-
tracks = await torrentPool.getSubtitleTracks(torrent, fileIndex);
|
|
290
|
-
} catch {
|
|
291
|
-
return null;
|
|
292
|
-
}
|
|
293
|
-
// BY the browser's number, not by position in this list. The list holds only
|
|
294
|
-
// the tracks that can become WebVTT, while the browser counts every subtitle
|
|
295
|
-
// stream ffmpeg lists — so on a file carrying a PGS or VobSub track the two
|
|
296
|
-
// ran one apart, and the request either found the wrong track or found none
|
|
297
|
-
// and fell through to the ffmpeg extraction below, which reads the whole film
|
|
298
|
-
// (752 s measured, 2026-08-19) for cues already in hand.
|
|
299
|
-
const track = Array.isArray(tracks)
|
|
300
|
-
? tracks.find((candidate) => candidate.declaredIndex === trackIndex) ?? null
|
|
301
|
-
: null;
|
|
302
|
-
if (!track) {
|
|
303
|
-
return null;
|
|
304
|
-
}
|
|
305
|
-
let held;
|
|
306
|
-
try {
|
|
307
|
-
held = await torrentPool.getSubtitleCues(torrent, fileIndex, track.trackNumber);
|
|
308
|
-
} catch {
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
311
|
-
if (!held || !Array.isArray(held.cues)) {
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
314
|
-
// Only what the browser does not have. The language is still detected from
|
|
315
|
-
// EVERYTHING held, because three new lines say much less about a language
|
|
316
|
-
// than the whole track does.
|
|
317
|
-
const cursor = held.cues.reduce((highest, cue) => Math.max(highest, Number(cue.seq) || 0), 0);
|
|
318
|
-
const fresh = Number.isInteger(since)
|
|
319
|
-
? held.cues.filter((cue) => (Number(cue.seq) || 0) > since)
|
|
320
|
-
: Number.isFinite(after)
|
|
321
|
-
? held.cues.filter((cue) => cue.startSeconds > after)
|
|
322
|
-
: held.cues;
|
|
323
|
-
const vtt = cuesToVtt(fresh, held.codecId);
|
|
324
|
-
const language = held.cues.length > 0
|
|
325
|
-
? detectLanguage(held.cues.map((cue) => cue.text).join("\n"))
|
|
326
|
-
: null;
|
|
327
|
-
return {
|
|
328
|
-
vtt,
|
|
329
|
-
language,
|
|
330
|
-
cursor,
|
|
331
|
-
coveredClusters: held.coveredClusters ?? 0,
|
|
332
|
-
indexedClusters: held.indexedClusters ?? 0
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* WebVTT from cues read out of the container. The end-time synthesis and ASS
|
|
338
|
-
* stripping are shared with the push path — see `finalizeCues` in
|
|
339
|
-
* `services/torrent-worker/subtitle-cues.js` — so a pulled cue and a pushed
|
|
340
|
-
* one read identically; this function only adds the WebVTT framing.
|
|
341
|
-
*
|
|
342
|
-
* @param {{ startSeconds: number, endSeconds: number | null, text: string }[]} cues
|
|
343
|
-
* @param {string} codecId
|
|
344
|
-
* @returns {string}
|
|
345
|
-
*/
|
|
346
|
-
function cuesToVtt(cues, codecId) {
|
|
347
|
-
const lines = ["WEBVTT", ""];
|
|
348
|
-
for (const cue of finalizeCues(cues, codecId)) {
|
|
349
|
-
lines.push(`${vttTime(cue.startSeconds)} --> ${vttTime(cue.endSeconds)}`);
|
|
350
|
-
lines.push(cue.text);
|
|
351
|
-
lines.push("");
|
|
352
|
-
}
|
|
353
|
-
return lines.join("\n");
|
|
354
|
-
}
|
|
355
200
|
|
|
356
|
-
/**
|
|
357
|
-
* A time in the form WebVTT requires.
|
|
358
|
-
*
|
|
359
|
-
* @param {number} seconds
|
|
360
|
-
* @returns {string}
|
|
361
|
-
*/
|
|
362
|
-
function vttTime(seconds) {
|
|
363
|
-
const safe = Math.max(0, seconds);
|
|
364
|
-
const hours = Math.floor(safe / 3600);
|
|
365
|
-
const minutes = Math.floor((safe % 3600) / 60);
|
|
366
|
-
const rest = safe % 60;
|
|
367
|
-
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:` +
|
|
368
|
-
`${rest.toFixed(3).padStart(6, "0")}`;
|
|
369
|
-
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file AVI container — RIFF.
|
|
3
|
+
*
|
|
4
|
+
* Minimal: only keyframe index via idx1 (AVIIF_KEYFRAME). Tracks are not
|
|
5
|
+
* used by current product beyond video — expose a single VideoTrack if needed.
|
|
6
|
+
* Spec: RIFF AVI, idx1 chunk at file end, OpenDML may lack idx1 → no index.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Container } from "./Container.js";
|
|
10
|
+
import { isAvi, readAviKeyframeTimes } from "../container-index/avi.js";
|
|
11
|
+
import { VideoTrack } from "../tracks/VideoTrack.js";
|
|
12
|
+
|
|
13
|
+
export class AviContainer extends Container {
|
|
14
|
+
get formatName() {
|
|
15
|
+
return "avi";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static detect(head) {
|
|
19
|
+
return isAvi(head);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async readTracks() {
|
|
23
|
+
const head = await this.readRange(0, Math.min(4095, this.fileSize - 1));
|
|
24
|
+
if (!head || !isAvi(head)) return [];
|
|
25
|
+
// AVI track table is minimal — expose one video track for uniformity.
|
|
26
|
+
return [new VideoTrack({
|
|
27
|
+
trackNumber: 1,
|
|
28
|
+
declaredIndex: 0,
|
|
29
|
+
codecId: "",
|
|
30
|
+
language: "",
|
|
31
|
+
languageBcp47: "",
|
|
32
|
+
name: "",
|
|
33
|
+
isEnabled: true,
|
|
34
|
+
isDefault: true,
|
|
35
|
+
declaresDefault: false
|
|
36
|
+
})];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async readKeyframeIndex() {
|
|
40
|
+
const r = await readAviKeyframeTimes(this.readRange, this.fileSize);
|
|
41
|
+
if (!r) return null;
|
|
42
|
+
if (Array.isArray(r)) return { times: r, tolerance: 0 };
|
|
43
|
+
return r;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Base Container — abstract per RFC 9559 / ISO 14496-12.
|
|
3
|
+
*
|
|
4
|
+
* A Container knows how to read its own format's track table and index.
|
|
5
|
+
* Concrete containers (MatroskaContainer, Mp4Container, AviContainer) implement
|
|
6
|
+
* spec-specific parsing. All byte access goes through `readRange(start,end)` so
|
|
7
|
+
* the class works over torrent piece windows.
|
|
8
|
+
*
|
|
9
|
+
* Spec refs:
|
|
10
|
+
* - Matroska RFC 9559 §5: EBML, Segment, SeekHead, Tracks, Cues, Clusters
|
|
11
|
+
* - MP4 ISO/IEC 14496-12 §8: ftyp, moov, trak, tkhd, mdhd, hdlr, elng, stbl
|
|
12
|
+
* - AVI RIFF §: LIST hdrl, idx1
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export class Container {
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} params
|
|
18
|
+
* @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
|
|
19
|
+
* @param {number} params.fileSize
|
|
20
|
+
* @param {string} [params.label]
|
|
21
|
+
*/
|
|
22
|
+
constructor({ readRange, fileSize, label = "" }) {
|
|
23
|
+
this.readRange = readRange;
|
|
24
|
+
this.fileSize = fileSize;
|
|
25
|
+
this.label = label;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @returns {string} Human name: "matroska" | "mp4" | "avi" | "unknown" */
|
|
29
|
+
get formatName() {
|
|
30
|
+
return "unknown";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether `head` (first bytes) looks like this container. */
|
|
34
|
+
static detect(_head) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* All tracks declared by the container, in container order.
|
|
40
|
+
* Includes disabled tracks (isEnabled=false) to preserve declaredIndex alignment with ffmpeg.
|
|
41
|
+
* @returns {Promise<import("../tracks/index.js").ContainerTrack[]>}
|
|
42
|
+
*/
|
|
43
|
+
async readTracks() {
|
|
44
|
+
throw new Error("readTracks not implemented");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Keyframe times for the video track, ascending seconds. Null when index absent (MPEG-TS, fragmented MP4, truncated).
|
|
49
|
+
* @returns {Promise<{times:number[],tolerance:number}|null>}
|
|
50
|
+
*/
|
|
51
|
+
async readKeyframeIndex() {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Subtitle-specific: where cues live (Matroska cluster positions or MP4 sample ranges).
|
|
57
|
+
* Returned via track objects' clusterPositions/samples, so base has no extra method — tracks carry it.
|
|
58
|
+
*/
|
|
59
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Container factory — detects format and returns the precise Container subclass.
|
|
3
|
+
*
|
|
4
|
+
* Sniffs first 16 bytes (same as container-index/index.js) and instantiates
|
|
5
|
+
* MatroskaContainer / Mp4Container / AviContainer. Falls back to null (unknown).
|
|
6
|
+
* Orchestrators depend on this, not on concrete constructors.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { MatroskaContainer } from "./MatroskaContainer.js";
|
|
10
|
+
import { Mp4Container } from "./Mp4Container.js";
|
|
11
|
+
import { AviContainer } from "./AviContainer.js";
|
|
12
|
+
|
|
13
|
+
const SNIFF_BYTES = 16;
|
|
14
|
+
|
|
15
|
+
export class ContainerFactory {
|
|
16
|
+
/**
|
|
17
|
+
* @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
|
|
18
|
+
* @param {number} fileSize
|
|
19
|
+
* @param {string} label
|
|
20
|
+
* @returns {Promise<import("./Container.js").Container|null>}
|
|
21
|
+
*/
|
|
22
|
+
static async create({ readRange, fileSize, label = "" }) {
|
|
23
|
+
if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) return null;
|
|
24
|
+
const head = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
|
|
25
|
+
if (!head) return null;
|
|
26
|
+
if (MatroskaContainer.detect(head)) return new MatroskaContainer({ readRange, fileSize, label });
|
|
27
|
+
if (Mp4Container.detect(head)) return new Mp4Container({ readRange, fileSize, label });
|
|
28
|
+
if (AviContainer.detect(head)) return new AviContainer({ readRange, fileSize, label });
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|