@torrent-tv/proxy 2.74.0 → 2.74.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,9 @@
1
+ ## 2.74.1
2
+
3
+ - **Chore**: The subtitle path chose a container twice, from two different kinds of evidence, and the two had to agree. Once by file extension, to decide which class to build; once by whether a track carried a sample list, to decide how to read its cues. Nothing enforced the agreement — a `.mkv` whose track somehow carried samples would have called a method Matroska does not have. There is one choice now and it is made from the BYTES: `ContainerFactory` sniffs the header, because that is what the muxer wrote while a name is what somebody typed, and only where the head is not downloaded — the cue walk asks the swarm for nothing — does the name answer instead. `Container.readHeldCues(plan, track, progress)` is then the one question: Matroska walks the clusters its Cues table names and fills every track from one walk, an MP4 reads the samples its own table states, and the caller chooses between them nowhere.
4
+ - **Chore**: A subtitle plan has one shape whichever container answers it. `Mp4Container` used to hand back its own raw table for the caller to reshape, inside the branch that has now gone; it returns the common shape itself, and says in its own words why `declared` is empty for an MP4 — the format has no element meaning "show this subtitle track by default", so nothing is shown unasked.
5
+ - **Chore**: `ContainerFactory.create` passes its parameters through whole, so a caller with a torrent's two readers gets a container that has both instead of one built with the fetching reader only.
6
+
1
7
  ## 2.74.0
2
8
 
3
9
  - **Chore**: Everything about subtitles is now in the classes that own it, and six loose modules are gone. `matroska-subtitles.js`, `matroska-blocks.js` and `mp4-subtitles.js` were a container's own specification written outside the container: the track table, the Cues, the blocks inside a cluster, the sample table. They are `MatroskaContainer` and `Mp4Container` now. `subtitle-markup.js` and the cue half of `subtitle-convert.js` were a codec's own rules written outside the track — which markup a codec carries, how to take it off, how to write the cues out as WebVTT — and are `TextSubtitleTrack`. Reading a subtitle FILE is `SubtitleFileContainer`, and reconciling ffmpeg's banner with a container's own table (`subtitle-defaults.js`) is `Container`. Folding them in exposed the duplication that having two homes had allowed: one Matroska helper and three MP4 box helpers existed twice over, with different parameter names and the same behaviour, and twenty-two element-id constants were declared in both places — checked pair by pair for disagreement before the second copy was dropped, and there was none.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.74.0",
3
+ "version": "2.74.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": {
@@ -114,6 +114,52 @@ export class Container {
114
114
  throw new Error("readTracks not implemented");
115
115
  }
116
116
 
117
+
118
+ /**
119
+ * This container's subtitle tracks and where their cues are, in ONE shape
120
+ * whichever container answers.
121
+ *
122
+ * `tracks` carries a `clusterPositions` list for a container that stores cues
123
+ * in clusters and a `samples` list for one that states each cue's own byte
124
+ * range; a caller reads neither, and asks {@link Container#readHeldCues}
125
+ * instead. `declared` is what the container says about its subtitle tracks in
126
+ * its own order, and empty means the container said nothing — a real answer,
127
+ * not a missing one.
128
+ *
129
+ * @returns {Promise<{ tracks: object[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
130
+ * Null where this container declares no subtitles at all.
131
+ */
132
+ async readSubtitlePlan() {
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * The cues this container can read RIGHT NOW for one track, without fetching.
138
+ *
139
+ * Every container answers this, and each reads what its own specification
140
+ * says: Matroska walks the clusters its Cues table names, an MP4 reads the
141
+ * samples its table states. The caller therefore chooses a container once —
142
+ * from the bytes — and never again. It used to choose twice, once by file
143
+ * extension for the container and once by whether a track carried a sample
144
+ * list for the reading, and two choices that must agree and are made from
145
+ * different evidence are a disagreement waiting to happen.
146
+ *
147
+ * `progress` is what has already been read, kept by the caller because it
148
+ * belongs to the file rather than to one pass: `walked` holds cluster
149
+ * positions, `harvested` holds sample offsets per track. Each container adds
150
+ * to the one it uses.
151
+ *
152
+ * @param {object} _plan - This file's subtitle plan.
153
+ * @param {object} _track - The track asked about.
154
+ * @param {{ walked: Set<number>, harvested: Map<number, Set<number>> }} _progress
155
+ * @returns {Promise<{ found: Map<number, object[]>, covered: number, indexed: number }>}
156
+ * `found` is track number to the cues found in THIS pass — Matroska fills
157
+ * every track from one walk, so it is a map and not a list.
158
+ */
159
+ async readHeldCues(_plan, _track, _progress) {
160
+ return { found: new Map(), covered: 0, indexed: 0 };
161
+ }
162
+
117
163
  /**
118
164
  * Keyframe times for the video track, ascending seconds. Null when index absent (MPEG-TS, fragmented MP4, truncated).
119
165
  * @returns {Promise<{times:number[],tolerance:number}|null>}
@@ -1,31 +1,55 @@
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
- }
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
+ * The container these bytes are, built over them.
18
+ *
19
+ * `params` is passed through whole, so a caller with a torrent's two readers
20
+ * gets a container that has both — see {@link Container}'s constructor.
21
+ *
22
+ * @param {{ readRange: (start:number,end:number)=>Promise<Buffer|null>, fileSize: number, label?: string, readHeld?: Function, isHeld?: Function }} params
23
+ * @returns {Promise<import("./Container.js").Container|null>}
24
+ */
25
+ static async create(params) {
26
+ const { readRange, fileSize } = params;
27
+ if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) return null;
28
+ const head = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
29
+ if (!head) return null;
30
+ if (MatroskaContainer.detect(head)) return new MatroskaContainer(params);
31
+ if (Mp4Container.detect(head)) return new Mp4Container(params);
32
+ if (AviContainer.detect(head)) return new AviContainer(params);
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * The container a file NAME suggests, for the moment the bytes cannot be
38
+ * sniffed.
39
+ *
40
+ * The head of a file nobody has opened is not downloaded, and the cue walk
41
+ * asks the swarm for nothing — so on that one path the name is all there is.
42
+ * It is a fallback and never a preference: the bytes decide wherever they can
43
+ * be read, because a name is what somebody typed and a header is what the
44
+ * muxer wrote.
45
+ *
46
+ * @param {string} name
47
+ * @returns {typeof MatroskaContainer | typeof Mp4Container | null}
48
+ */
49
+ static byName(name) {
50
+ const text = String(name ?? "");
51
+ if (/\.(mp4|m4v|m4a)$/i.test(text)) return Mp4Container;
52
+ if (/\.(mkv|mka|webm)$/i.test(text)) return MatroskaContainer;
53
+ return null;
54
+ }
55
+ }
@@ -344,6 +344,17 @@ export class MatroskaContainer extends Container {
344
344
  * @returns {Promise<Map<number, {startSeconds: number, endSeconds: number|null, text: string}[]>>}
345
345
  * Track number to the cues found in THIS pass.
346
346
  */
347
+ async readHeldCues(plan, track, progress) {
348
+ const found = await this.walkHeldClusters(plan, progress.walked);
349
+ return {
350
+ found,
351
+ // Every track is filled by the same walk, so this is a fact about the
352
+ // FILE and reads the same whichever track asked.
353
+ covered: progress.walked.size,
354
+ indexed: track?.clusterPositions?.length ?? 0
355
+ };
356
+ }
357
+
347
358
  async walkHeldClusters(plan, walked) {
348
359
  /** @type {Map<number, object[]>} */
349
360
  const found = new Map();
@@ -118,8 +118,34 @@ export class Mp4Container extends Container {
118
118
  *
119
119
  * @returns {Promise<object|null>}
120
120
  */
121
- readSubtitlePlan() {
122
- return Mp4Container.readSubtitlePlan(this.readRange, this.fileSize);
121
+ async readSubtitlePlan() {
122
+ const plan = await Mp4Container.readSubtitlePlan(this.readRange, this.fileSize);
123
+ if (!plan) {
124
+ return null;
125
+ }
126
+ // An MP4 states every sample's byte range in its own table, so a cue costs
127
+ // its own few dozen bytes rather than the cluster around it — which is why
128
+ // there are `samples` here and no `clusterPositions`.
129
+ //
130
+ // An MP4 has no element meaning "show this subtitle track by default", so
131
+ // `declared` is empty: the container states nothing, and nothing is shown
132
+ // unasked.
133
+ return {
134
+ tracks: plan.tracks.map((track, order) => ({
135
+ trackNumber: track.trackId,
136
+ declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
137
+ codecId: track.format,
138
+ language: track.language,
139
+ name: "",
140
+ isDefault: order === 0,
141
+ codecPrivate: "",
142
+ clusterPositions: [],
143
+ samples: track.samples
144
+ })),
145
+ declared: [],
146
+ secondsPerTick: 0.001,
147
+ segmentDataOffset: 0
148
+ };
123
149
  }
124
150
 
125
151
 
@@ -138,6 +164,20 @@ export class Mp4Container extends Container {
138
164
  * @returns {Promise<{startSeconds: number, endSeconds: number, text: string}[]>}
139
165
  * The cues found in THIS pass.
140
166
  */
167
+ async readHeldCues(_plan, track, progress) {
168
+ let harvested = progress.harvested.get(track.trackNumber);
169
+ if (!harvested) {
170
+ harvested = new Set();
171
+ progress.harvested.set(track.trackNumber, harvested);
172
+ }
173
+ const cues = await this.readHeldSamples(track, harvested);
174
+ return {
175
+ found: cues.length > 0 ? new Map([[track.trackNumber, cues]]) : new Map(),
176
+ covered: harvested.size,
177
+ indexed: track?.samples?.length ?? 0
178
+ };
179
+ }
180
+
141
181
  async readHeldSamples(track, harvested) {
142
182
  const found = [];
143
183
  for (const sample of track?.samples ?? []) {
@@ -17,8 +17,7 @@
17
17
  * Reading the clusters costs nothing extra at all.
18
18
  */
19
19
 
20
- import { MatroskaContainer } from "../container/MatroskaContainer.js";
21
- import { Mp4Container } from "../container/Mp4Container.js";
20
+ import { ContainerFactory } from "../container/ContainerFactory.js";
22
21
  import { TextSubtitleTrack } from "../tracks/TextSubtitleTrack.js";
23
22
  import { detectLanguage } from "../language-detect.js";
24
23
  import { logger } from "../../utils/logger.js";
@@ -111,18 +110,25 @@ function readHeld(file, start, end) {
111
110
  /**
112
111
  * A container over one file of a torrent, told how to read it.
113
112
  *
114
- * The container is given two functions and never the torrent: whether a byte
115
- * range is already downloaded, and how to read one without asking the swarm for
116
- * anything. That is the whole of what this layer knows that the container does
117
- * not, and reducing it to two functions is what lets the reading itself live
118
- * where the format is specified.
113
+ * The container is given two readers and a predicate and never the torrent:
114
+ * whether a byte range is already downloaded, how to read one without asking
115
+ * the swarm, and how to read one that may need fetching. That is the whole of
116
+ * what this layer knows and the container does not.
119
117
  *
118
+ * WHICH container is decided from the bytes, by the factory's sniff — the
119
+ * header is what the muxer wrote, and a file name is what somebody typed. Only
120
+ * where the head is not downloaded, and so cannot be sniffed without asking the
121
+ * swarm, does the name answer instead. One choice either way: the caller never
122
+ * decides a second time from a track's shape, which is two decisions from
123
+ * different evidence that have to agree.
124
+ *
125
+ * @param {object} state - This file's state; the container is kept on it.
120
126
  * @param {object} torrent
121
127
  * @param {object} file
122
- * @returns {MatroskaContainer | Mp4Container}
128
+ * @returns {Promise<import("../container/Container.js").Container | null>}
123
129
  */
124
- function containerOver(state, torrent, file) {
125
- if (state.container) {
130
+ async function containerOver(state, torrent, file) {
131
+ if (state.container !== null) {
126
132
  return state.container;
127
133
  }
128
134
  const held = async (start, end) => readHeld(file, start, Math.min(end, file.length - 1));
@@ -133,8 +139,13 @@ function containerOver(state, torrent, file) {
133
139
  fileSize: file.length,
134
140
  label: String(file.name ?? "")
135
141
  };
136
- const name = String(file.name ?? "");
137
- state.container = /\.(mp4|m4v)$/i.test(name) ? new Mp4Container(params) : new MatroskaContainer(params);
142
+ const sniffed = await ContainerFactory.create(params);
143
+ if (sniffed) {
144
+ state.container = sniffed;
145
+ return state.container;
146
+ }
147
+ const ByName = ContainerFactory.byName(file.name);
148
+ state.container = ByName ? new ByName(params) : null;
138
149
  return state.container;
139
150
  }
140
151
 
@@ -251,33 +262,8 @@ async function readPlan(torrent, fileIndex, state) {
251
262
  state.plan = empty;
252
263
  return state.plan;
253
264
  }
254
- const container = containerOver(state, torrent, file);
255
- const name = String(file.name);
256
- if (/\.mp4$/i.test(name) || /\.m4v$/i.test(name)) {
257
- // An MP4 states every sample's byte range in its own table, so a cue costs
258
- // its own few dozen bytes rather than the cluster around it. The samples
259
- // are carried as `clusterPositions` of one byte range each, so the harvest
260
- // treats both containers the same way.
261
- const mp4 = await container.readSubtitlePlan();
262
- state.plan = mp4
263
- ? {
264
- ...empty,
265
- tracks: mp4.tracks.map((track, order) => ({
266
- trackNumber: track.trackId,
267
- declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
268
- codecId: track.format,
269
- language: track.language,
270
- name: "",
271
- isDefault: order === 0,
272
- codecPrivate: "",
273
- clusterPositions: [],
274
- samples: track.samples
275
- }))
276
- }
277
- : empty;
278
- return state.plan;
279
- }
280
- if (!/\.mkv$/i.test(name) && !/\.webm$/i.test(name)) {
265
+ const container = await containerOver(state, torrent, file);
266
+ if (!container) {
281
267
  state.plan = empty;
282
268
  return state.plan;
283
269
  }
@@ -372,52 +358,33 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
372
358
  */
373
359
  async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
374
360
  const file = torrent.files[fileIndex];
375
- const container = containerOver(state, torrent, file);
376
- const cuesOf = (number) => {
377
- let held = state.cues.get(number);
378
- if (!held) {
379
- held = [];
380
- state.cues.set(number, held);
381
- }
382
- return held;
383
- };
384
-
385
- if (Array.isArray(track.samples)) {
386
- // An MP4 states every cue's byte range, so the container reads per sample.
387
- let harvested = state.harvested.get(trackNumber);
388
- if (!harvested) {
389
- harvested = new Set();
390
- state.harvested.set(trackNumber, harvested);
391
- }
392
- const cues = cuesOf(trackNumber);
393
- for (const cue of await container.readHeldSamples(track, harvested)) {
394
- cues.push({ ...cue, seq: nextSeq(state, trackNumber) });
395
- }
396
- cues.sort((left, right) => left.startSeconds - right.startSeconds);
397
- return {
398
- cues,
399
- coveredClusters: harvested.size,
400
- indexedClusters: track.samples.length,
401
- track
402
- };
361
+ const container = await containerOver(state, torrent, file);
362
+ if (!container) {
363
+ return { cues: [], coveredClusters: 0, indexedClusters: 0, track };
403
364
  }
404
365
 
405
- // Matroska: one walk of the file fills every track, because a cluster carries
406
- // the blocks of every track that has anything to say over its span.
407
- const found = await container.walkHeldClusters(plan, state.walked);
366
+ // One question, whichever container this is. Matroska walks the clusters its
367
+ // Cues table names and fills every track from one walk; an MP4 reads the
368
+ // samples its own table states for this track. Nothing here chooses between
369
+ // them, which is the point: the container was chosen once, from the bytes.
370
+ const { found, covered, indexed } = await container.readHeldCues(plan, track, state);
371
+
408
372
  for (const [number, cues] of found) {
409
- const into = cuesOf(number);
373
+ let into = state.cues.get(number);
374
+ if (!into) {
375
+ into = [];
376
+ state.cues.set(number, into);
377
+ }
410
378
  for (const cue of cues) {
411
379
  into.push({ ...cue, seq: nextSeq(state, number) });
412
380
  }
413
381
  into.sort((left, right) => left.startSeconds - right.startSeconds);
414
382
  }
383
+
415
384
  return {
416
385
  cues: state.cues.get(trackNumber) ?? [],
417
- // Every track is filled by the same walk, so this is a fact about the FILE
418
- // and reads the same whichever track asked.
419
- coveredClusters: state.walked.size,
420
- indexedClusters: track.clusterPositions.length,
386
+ coveredClusters: covered,
387
+ indexedClusters: indexed,
421
388
  track
422
389
  };
423
390
  }