@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.63.0",
3
+ "version": "2.64.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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
- // Short per-request budget: if the file header is not downloaded yet the
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 { finalizeCues } from "../../../services/torrent-worker/subtitle-cues.js";
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
- const sourceRecord = sourceRegistry.get(sourceKey);
62
- if (!sourceRecord) {
63
- return reply.code(404).send({ error: "Source key was not found." });
64
- }
65
- const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
66
- const file = torrent.files[fileIndex];
67
- if (!file) {
68
- return reply.code(404).send({ error: "File index was not found in torrent." });
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
- // ---- External subtitle FILE (no trackIndex) -----------------------------
72
- if (!hasTrackIndex) {
73
- const name = typeof file.name === "string" ? file.name : "";
74
- const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
75
- const release = torrentPool.acquireFile(torrent, fileIndex);
76
- try {
77
- const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
78
- const text = decodeSubtitleBytes(bytes);
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
- return reply.send(vtt);
87
- } catch (error) {
88
- const message = error instanceof Error ? error.message : String(error);
89
- return reply.code(502).send({ error: `Could not read subtitle file: ${message}` });
90
- } finally {
91
- release();
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 (ffmpeg extraction, streamed) -----------------------
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
+ }