@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 +6 -0
- package/package.json +1 -1
- package/services/container/Container.js +46 -0
- package/services/container/ContainerFactory.js +55 -31
- package/services/container/MatroskaContainer.js +11 -0
- package/services/container/Mp4Container.js +42 -2
- package/services/torrent-worker/subtitle-cues.js +42 -75
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
|
@@ -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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* @
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
115
|
-
* range is already downloaded,
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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 {
|
|
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
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
coveredClusters: state.walked.size,
|
|
420
|
-
indexedClusters: track.clusterPositions.length,
|
|
386
|
+
coveredClusters: covered,
|
|
387
|
+
indexedClusters: indexed,
|
|
421
388
|
track
|
|
422
389
|
};
|
|
423
390
|
}
|