@torrent-tv/proxy 2.88.6 → 2.89.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/AGENTS.md +21 -12
- package/CHANGELOG.md +2285 -2267
- package/assets/calibration/audio/NOTICE.md +19 -19
- package/assets/calibration/audio/manifest.json +98 -98
- package/docs/container-architecture.md +77 -22
- package/docs/playback-start-forecast.md +56 -56
- package/main2.cpuprofile +1 -0
- package/package.json +1 -1
- package/scripts/build-audio-samples.mjs +31 -31
- package/server.js +42 -27
- package/services/encode/OutputOpening.js +5 -4
- package/services/encode/audio-calibration.js +67 -67
- package/services/encode/audio-work-rate.js +15 -15
- package/services/media/ContainerOrchestrator.js +99 -46
- package/services/media/KeyframeTables.js +61 -21
- package/services/media/SubtitleCues.js +255 -183
- package/services/media/container/AviContainer.js +5 -3
- package/services/media/container/Container.js +66 -43
- package/services/media/container/ContainerFactory.js +8 -26
- package/services/media/container/MatroskaContainer.js +719 -1182
- package/services/media/container/Mp4Container.js +83 -73
- package/services/media/container/ebml-stream.js +174 -0
- package/services/media/container/matroska-clusters.js +1165 -0
- package/services/media/container/unavailable.js +77 -0
- package/services/media/tracks/ContainerTrack.js +9 -0
- package/services/media/tracks/TextSubtitleTrack.js +9 -1
- package/services/server/playback-readiness.js +516 -516
- package/services/torrent/worker/client.js +836 -765
- package/services/torrent/worker/held-bytes.js +84 -8
- package/services/torrent/worker/worker.js +10 -2
- package/services/transport/data-channel-handler.js +7 -3
- package/services/viewer/PriorityMap.js +31 -0
- package/test/audio-calibration.test.js +23 -23
- package/test/coalesce.test.js +39 -0
- package/test/held-read-ownership.test.js +77 -0
- package/test/helpers/held-file.js +50 -2
- package/test/helpers/matroska-file.js +296 -0
- package/test/keyframe-table-budget.test.js +21 -0
- package/test/matroska-cluster-discovery.test.js +323 -0
- package/test/reads-not-yet-arrived.test.js +218 -0
- package/test/subtitle-cue-walk.test.js +16 -1
- package/test/subtitle-plan-waits.test.js +125 -0
- package/test/subtitle-track-numbering.test.js +8 -3
- package/test/viewer-starts-on-map.test.js +27 -0
- package/test/whole-file-held-read.test.js +30 -0
- package/utils/coalesce.js +41 -0
|
@@ -5,48 +5,58 @@
|
|
|
5
5
|
* It walks ONLY what is downloaded: switching subtitles on must never pull
|
|
6
6
|
* bytes the viewer is not waiting for. The head and the Cues table are the one
|
|
7
7
|
* exception and are fetched — they are kilobytes, they are needed before
|
|
8
|
-
* anything can be offered
|
|
9
|
-
*
|
|
8
|
+
* anything can be offered — and they are read by the file's ONE container, the
|
|
9
|
+
* one `ContainerOrchestrator` keeps. This file used to build a second container
|
|
10
|
+
* of its own over the same file and read both again; when its read of the Cues
|
|
11
|
+
* table had not arrived it kept "no clusters" for the life of the process, and
|
|
12
|
+
* an embedded track showed nothing for a whole session
|
|
13
|
+
* (`research/subtitles-never-appear-2026-10-01.md`).
|
|
14
|
+
*
|
|
15
|
+
* **A plan is kept only once it has been read.** While the bytes it needs have
|
|
16
|
+
* not arrived the plan is "not ready": nothing is kept, the walk does nothing,
|
|
17
|
+
* and the next pass — on the next arrival of pieces — reads again.
|
|
10
18
|
*
|
|
11
19
|
* **The torrent is not here, and that is the point.** This layer is handed a
|
|
12
|
-
* `HeldFile`: a name, a length,
|
|
13
|
-
* read one of them without fetching
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* there, which put a container parse — the media layer's work — in the thread
|
|
17
|
-
* that owns the swarm, and made every answer something to carry back across a
|
|
18
|
-
* channel.
|
|
20
|
+
* `HeldFile`: a name, a length, the file's container, which ranges are
|
|
21
|
+
* downloaded whole, how to read one of them without fetching, the portion a
|
|
22
|
+
* read may span, and where the viewers stand. Piece length, file offsets and
|
|
23
|
+
* the bitfield are the torrent's words and stay in the torrent's thread.
|
|
19
24
|
*
|
|
20
|
-
* What is genuinely this file's own
|
|
21
|
-
*
|
|
22
|
-
*
|
|
25
|
+
* What is genuinely this file's own: the found-order cursor a browser follows,
|
|
26
|
+
* the per-file state, one walk of a file at a time, and taking back the cues of
|
|
27
|
+
* a cluster the container withdraws.
|
|
23
28
|
*/
|
|
24
29
|
|
|
25
|
-
import { ContainerFactory } from "./container/ContainerFactory.js";
|
|
26
30
|
import { TextSubtitleTrack } from "./tracks/TextSubtitleTrack.js";
|
|
27
31
|
import { detectLanguage } from "./tracks/language-detect.js";
|
|
32
|
+
import { isUnavailable, strictReader } from "./container/unavailable.js";
|
|
28
33
|
import { logger } from "../../utils/logger.js";
|
|
29
34
|
|
|
30
|
-
|
|
31
35
|
/**
|
|
32
36
|
* One file of one torrent, as much of it as this layer is allowed to know.
|
|
33
37
|
*
|
|
34
|
-
* Four plain values and two functions: what it is called, how long it is, which
|
|
35
|
-
* of it is downloaded whole, and how to read a range of that without asking the
|
|
36
|
-
* swarm for anything. No torrent, no bitfield, no piece length — those are the
|
|
37
|
-
* torrent's words, and turning them into these is its job.
|
|
38
|
-
*
|
|
39
38
|
* @typedef {object} HeldFile
|
|
40
39
|
* @property {string} sourceKey
|
|
41
40
|
* @property {number} fileIndex
|
|
42
41
|
* @property {string} name
|
|
43
42
|
* @property {number} length
|
|
43
|
+
* @property {() => Promise<import("./container/Container.js").Container | null>} container -
|
|
44
|
+
* The file's one container. Throws `BytesUnavailable` while its head has not
|
|
45
|
+
* arrived; null where the bytes are no format this proxy knows.
|
|
44
46
|
* @property {() => Promise<Array<[number, number]>>} heldRanges - Ascending,
|
|
45
47
|
* non-overlapping, inclusive offsets within the file.
|
|
46
48
|
* @property {(start: number, end: number) => Promise<Buffer | null>} readHeld -
|
|
47
49
|
* Null where those bytes are not there, which is never fetched.
|
|
50
|
+
* @property {number} [portionBytes] - The largest read one step may make.
|
|
51
|
+
* @property {() => number[]} [wantedSeconds] - Where viewers stand, in seconds.
|
|
52
|
+
* @property {() => void} [askAgain] - Run the walk again: said when a pass left
|
|
53
|
+
* readable clusters for the next one, so they are read without waiting for
|
|
54
|
+
* another arrival of pieces — the file may be downloaded whole.
|
|
48
55
|
*/
|
|
49
56
|
|
|
57
|
+
/** How often one file's walk says what it has done, at most. */
|
|
58
|
+
const WALK_REPORT_INTERVAL_MS = 60_000;
|
|
59
|
+
|
|
50
60
|
/**
|
|
51
61
|
* Whether a range falls entirely inside one of the held runs.
|
|
52
62
|
*
|
|
@@ -67,79 +77,43 @@ function rangeHolds(ranges, start, end) {
|
|
|
67
77
|
return false;
|
|
68
78
|
}
|
|
69
79
|
|
|
70
|
-
/** @type {Map<string,
|
|
80
|
+
/** @type {Map<string, object>} */
|
|
71
81
|
const byFile = new Map();
|
|
72
82
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* A container over one file of a torrent, told how to read it.
|
|
77
|
-
*
|
|
78
|
-
* The container is given two readers and a predicate and never the torrent:
|
|
79
|
-
* whether a byte range is already downloaded, how to read one without asking
|
|
80
|
-
* the swarm, and how to read one that may need fetching. That is the whole of
|
|
81
|
-
* what this layer knows and the container does not.
|
|
82
|
-
*
|
|
83
|
-
* WHICH container is decided from the bytes, by the factory's sniff — the
|
|
84
|
-
* header is what the muxer wrote, and a file name is what somebody typed. Only
|
|
85
|
-
* where the head is not downloaded, and so cannot be sniffed without asking the
|
|
86
|
-
* swarm, does the name answer instead. One choice either way: the caller never
|
|
87
|
-
* decides a second time from a track's shape, which is two decisions from
|
|
88
|
-
* different evidence that have to agree.
|
|
89
|
-
*
|
|
90
|
-
* @param {object} state - This file's state; the container is kept on it.
|
|
91
|
-
* @param {import("./SubtitleCues.js").HeldFile} source
|
|
92
|
-
* @returns {Promise<import("./container/Container.js").Container | null>}
|
|
93
|
-
*/
|
|
94
|
-
async function containerOver(state, source) {
|
|
95
|
-
if (state.container !== null) {
|
|
96
|
-
return state.container;
|
|
97
|
-
}
|
|
98
|
-
const last = Number(source.length) - 1;
|
|
99
|
-
const held = async (start, end) => source.readHeld(start, Math.min(end, last));
|
|
100
|
-
const params = {
|
|
101
|
-
readRange: held,
|
|
102
|
-
readHeld: held,
|
|
103
|
-
// Answered from the list of ranges this file has downloaded WHOLE, taken
|
|
104
|
-
// once per pass rather than asked per cluster: a pass asks about every
|
|
105
|
-
// cluster of the file, and the torrent that knows is on the other thread.
|
|
106
|
-
isHeld: (start, end) => rangeHolds(state.held, start, Math.min(end, last)),
|
|
107
|
-
fileSize: source.length,
|
|
108
|
-
label: String(source.name ?? "")
|
|
109
|
-
};
|
|
110
|
-
const sniffed = await ContainerFactory.create(params);
|
|
111
|
-
if (sniffed) {
|
|
112
|
-
state.container = sniffed;
|
|
113
|
-
return state.container;
|
|
114
|
-
}
|
|
115
|
-
const ByName = ContainerFactory.byName(source.name);
|
|
116
|
-
state.container = ByName ? new ByName(params) : null;
|
|
117
|
-
return state.container;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
83
|
/**
|
|
121
|
-
* The subtitle
|
|
122
|
-
*
|
|
123
|
-
* The head and the Cues table are two short reads, and they ARE fetched if
|
|
124
|
-
* missing — they are kilobytes, they are needed before anything can be offered,
|
|
125
|
-
* and the codec probe has already pulled the head for every file that plays.
|
|
84
|
+
* The subtitle plan of a file, read once it can be.
|
|
126
85
|
*
|
|
127
|
-
* @param {
|
|
86
|
+
* @param {HeldFile} source
|
|
128
87
|
* @param {string} key - `sourceKey:fileIndex`.
|
|
129
|
-
* @returns {Promise<object | null>}
|
|
88
|
+
* @returns {Promise<object | null>} Null while the plan's bytes have not
|
|
89
|
+
* arrived — "not ready", not "no subtitles".
|
|
130
90
|
*/
|
|
131
91
|
async function planFor(source, key) {
|
|
132
92
|
const state = stateFor(key);
|
|
133
93
|
if (state.plan !== null) {
|
|
134
94
|
return state.plan;
|
|
135
95
|
}
|
|
136
|
-
// The head and the Cues table are
|
|
96
|
+
// The head and the Cues table are reads that DO wait on the swarm, so two
|
|
137
97
|
// callers arriving together would both make them. One promise, awaited by
|
|
138
98
|
// whoever asks while it is in flight.
|
|
139
99
|
if (!state.planPromise) {
|
|
140
|
-
state.planPromise = readPlan(source, state)
|
|
141
|
-
|
|
142
|
-
|
|
100
|
+
state.planPromise = readPlan(source, state)
|
|
101
|
+
.catch((error) => {
|
|
102
|
+
if (!isUnavailable(error)) {
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
if (!state.waitingSaid) {
|
|
106
|
+
state.waitingSaid = true;
|
|
107
|
+
logger.info(
|
|
108
|
+
`subtitles: the plan of "${String(source.name).slice(0, 40)}" waits for its bytes ` +
|
|
109
|
+
`(${error.message}); read again when pieces arrive`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
})
|
|
114
|
+
.finally(() => {
|
|
115
|
+
state.planPromise = null;
|
|
116
|
+
});
|
|
143
117
|
}
|
|
144
118
|
return state.planPromise;
|
|
145
119
|
}
|
|
@@ -161,26 +135,26 @@ function stateFor(key) {
|
|
|
161
135
|
state = {
|
|
162
136
|
plan: null,
|
|
163
137
|
planPromise: null,
|
|
138
|
+
waitingSaid: false,
|
|
164
139
|
forgotten: false,
|
|
165
140
|
// One walk of a file at a time — see `serialize`.
|
|
166
141
|
chain: Promise.resolve(),
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
container: null,
|
|
171
|
-
// Which byte ranges of this file are downloaded WHOLE, as of the start of
|
|
172
|
-
// the pass now running. Taken once and read many times: a pass asks about
|
|
173
|
-
// every cluster of the file, and what knows the answer is on the other
|
|
174
|
-
// thread.
|
|
175
|
-
held: null,
|
|
176
|
-
harvested: new Map(),
|
|
142
|
+
// What the container has read of this file between passes. Each
|
|
143
|
+
// container keeps its own part under its own name.
|
|
144
|
+
progress: {},
|
|
177
145
|
cues: new Map(),
|
|
178
146
|
seq: new Map(),
|
|
179
|
-
walked: new Set(),
|
|
180
147
|
// The found-order cursor of the last cue PUSHED for each track, so a
|
|
181
|
-
// second warmup pass sends only what a first one did not
|
|
182
|
-
|
|
183
|
-
|
|
148
|
+
// second warmup pass sends only what a first one did not.
|
|
149
|
+
pushed: new Map(),
|
|
150
|
+
// Cues taken back since the last push, by track: their found-order
|
|
151
|
+
// numbers, which is how a browser names the cues it holds.
|
|
152
|
+
withdrawn: new Map(),
|
|
153
|
+
// The tracks whose container-default language the text was seen to
|
|
154
|
+
// contradict — said once each.
|
|
155
|
+
disagreementSaid: new Set(),
|
|
156
|
+
lastReportMs: 0,
|
|
157
|
+
lastStats: null
|
|
184
158
|
};
|
|
185
159
|
byFile.set(key, state);
|
|
186
160
|
}
|
|
@@ -193,12 +167,9 @@ function stateFor(key) {
|
|
|
193
167
|
*
|
|
194
168
|
* Both entry points here — a browser's own pull and the warmup that runs ahead
|
|
195
169
|
* of it — mark a cluster as walked only AFTER reading and parsing it, which is
|
|
196
|
-
*
|
|
197
|
-
* arriving in between
|
|
198
|
-
*
|
|
199
|
-
* times over and the same line could be pushed twice under different `seq`
|
|
200
|
-
* numbers. Each of those reads is a WebTorrent file stream, which selects and
|
|
201
|
-
* deselects its pieces, so the repetition reached the piece picker as well.
|
|
170
|
+
* several suspension points later; without one walk at a time, two calls
|
|
171
|
+
* arriving in between read and parsed the same cluster and could push the
|
|
172
|
+
* same line twice under different `seq` numbers.
|
|
202
173
|
*
|
|
203
174
|
* @template T
|
|
204
175
|
* @param {object} state
|
|
@@ -214,41 +185,43 @@ function serialize(state, work) {
|
|
|
214
185
|
}
|
|
215
186
|
|
|
216
187
|
/**
|
|
217
|
-
* Read one file's subtitle plan — the tracks it declares and where the
|
|
218
|
-
* holding them are.
|
|
188
|
+
* Read one file's subtitle plan — the tracks it declares and where the cues
|
|
189
|
+
* holding them are. Throws `BytesUnavailable` while those bytes are not here.
|
|
219
190
|
*
|
|
220
|
-
* @param {
|
|
191
|
+
* @param {HeldFile} source
|
|
221
192
|
* @param {object} state
|
|
222
193
|
* @returns {Promise<object>}
|
|
223
194
|
*/
|
|
224
195
|
async function readPlan(source, state) {
|
|
225
196
|
// `declared` is what the container itself says about its subtitle tracks, in
|
|
226
197
|
// its own order. Empty means the container said nothing — which is a real
|
|
227
|
-
// answer and not a missing one
|
|
228
|
-
// element that means "show this subtitle track by default", so it declares
|
|
229
|
-
// nothing however many tracks it carries.
|
|
198
|
+
// answer and not a missing one.
|
|
230
199
|
const empty = { tracks: [], declared: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
|
|
231
|
-
if (!source || !(Number(source.length) > 0)) {
|
|
200
|
+
if (!source || !(Number(source.length) > 0) || typeof source.container !== "function") {
|
|
232
201
|
state.plan = empty;
|
|
233
202
|
return state.plan;
|
|
234
203
|
}
|
|
235
|
-
|
|
236
|
-
const container = await containerOver(state, source);
|
|
204
|
+
const container = await source.container();
|
|
237
205
|
if (!container) {
|
|
206
|
+
// The bytes were read and are no format this proxy knows.
|
|
238
207
|
state.plan = empty;
|
|
239
208
|
return state.plan;
|
|
240
209
|
}
|
|
241
210
|
const plan = await container.readSubtitlePlan();
|
|
211
|
+
state.container = container;
|
|
242
212
|
state.plan = plan ?? empty;
|
|
243
213
|
if (state.plan.tracks.length > 0) {
|
|
244
214
|
logger.info(
|
|
245
215
|
`subtitles: "${String(source.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s) ` +
|
|
246
|
-
`of ${state.plan.declared.length} declared
|
|
216
|
+
`of ${state.plan.declared.length} declared` +
|
|
217
|
+
(state.plan.cuesState ? `, Cues ${state.plan.cuesState}` : "") +
|
|
218
|
+
" — " +
|
|
247
219
|
state.plan.tracks
|
|
248
220
|
// `s:N` is the number the browser names (ffmpeg's own), and it differs
|
|
249
221
|
// from the file's track number whenever a picture track sits among them.
|
|
250
222
|
.map((track) => `s:${track.declaredIndex}=${track.trackNumber}:${track.language || "?"}` +
|
|
251
|
-
`${track.
|
|
223
|
+
`${track.languageSource === "default" ? "(default)" : ""}` +
|
|
224
|
+
`${track.name ? `/${track.name}` : ""}(${(track.clusterPositions ?? track.samples ?? []).length} indexed)`)
|
|
252
225
|
.join(" ")
|
|
253
226
|
);
|
|
254
227
|
}
|
|
@@ -260,15 +233,8 @@ async function readPlan(source, state) {
|
|
|
260
233
|
*
|
|
261
234
|
* A cue's TIME cannot serve as one. Cues are harvested out of whichever
|
|
262
235
|
* clusters happen to be downloaded, and those are not contiguous, so the set
|
|
263
|
-
* grows in the middle as well as at the end.
|
|
264
|
-
*
|
|
265
|
-
* cues that turn up BEHIND that mark afterwards — which is exactly the stretch
|
|
266
|
-
* it is about to play. Measured 2026-08-20 on a viewer at 272 s: one answer
|
|
267
|
-
* carried cues out to 1176 s, and from then on every cue between the two was
|
|
268
|
-
* filtered away for the rest of the session, with 59 of 276 clusters read.
|
|
269
|
-
*
|
|
270
|
-
* Found-order is monotonic by construction, so `?since=<n>` is exact however
|
|
271
|
-
* the file arrives.
|
|
236
|
+
* grows in the middle as well as at the end. Found-order is monotonic by
|
|
237
|
+
* construction, so `?since=<n>` is exact however the file arrives.
|
|
272
238
|
*
|
|
273
239
|
* @param {{ seq: Map<number, number> }} state
|
|
274
240
|
* @param {number} trackNumber
|
|
@@ -280,12 +246,51 @@ function nextSeq(state, trackNumber) {
|
|
|
280
246
|
return next;
|
|
281
247
|
}
|
|
282
248
|
|
|
249
|
+
/**
|
|
250
|
+
* What may be read on this pass: the held ranges taken once, a strict read of
|
|
251
|
+
* them, the portion a read may span, and where the viewers stand.
|
|
252
|
+
*
|
|
253
|
+
* @param {HeldFile} source
|
|
254
|
+
* @returns {Promise<import("./container/Container.js").HeldReader>}
|
|
255
|
+
*/
|
|
256
|
+
async function heldReaderOf(source) {
|
|
257
|
+
const ranges = await source.heldRanges();
|
|
258
|
+
const last = Number(source.length) - 1;
|
|
259
|
+
// Each read hands the loop back before the walk goes on. The answer comes from
|
|
260
|
+
// the torrent's thread, which replies faster than this thread empties its
|
|
261
|
+
// message port, and without the turn the port takes reply after reply in one
|
|
262
|
+
// go: measured on the addon host 2026-10-01, timers then fired 8-12 ms late
|
|
263
|
+
// at the 99th percentile during a walk, against 2-5 ms with it and 0-3 ms
|
|
264
|
+
// with no walk at all.
|
|
265
|
+
const read = strictReader(async (start, end) => {
|
|
266
|
+
try {
|
|
267
|
+
return await source.readHeld(start, Math.min(end, last));
|
|
268
|
+
} finally {
|
|
269
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
270
|
+
}
|
|
271
|
+
}, Number(source.length));
|
|
272
|
+
// Read again between clusters, so a pass can stop when a viewer has moved.
|
|
273
|
+
const wantedSeconds = () => {
|
|
274
|
+
try {
|
|
275
|
+
const stated = source.wantedSeconds?.();
|
|
276
|
+
return Array.isArray(stated) ? stated.filter((value) => Number.isFinite(value)) : [];
|
|
277
|
+
} catch {
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
return {
|
|
282
|
+
ranges,
|
|
283
|
+
isHeld: (start, end) => rangeHolds(ranges, start, Math.min(end, last)),
|
|
284
|
+
read,
|
|
285
|
+
portionBytes: Number.isFinite(source.portionBytes) && source.portionBytes > 0 ? source.portionBytes : Number.POSITIVE_INFINITY,
|
|
286
|
+
wantedSeconds
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
283
290
|
/**
|
|
284
291
|
* Every cue of one track that can be read from what is already downloaded.
|
|
285
292
|
*
|
|
286
|
-
* @param {
|
|
287
|
-
* @param {number} fileIndex
|
|
288
|
-
* @param {string} sourceKey
|
|
293
|
+
* @param {HeldFile} source
|
|
289
294
|
* @param {number} trackNumber
|
|
290
295
|
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
291
296
|
*/
|
|
@@ -294,9 +299,8 @@ export async function cuesHeldFor(source, trackNumber) {
|
|
|
294
299
|
// A source that cannot say which of itself is downloaded makes every range
|
|
295
300
|
// read as "not there", so the walk reads nothing and returns an empty list —
|
|
296
301
|
// which is also what a file with no cues yet returns, and that is how this
|
|
297
|
-
// went unnoticed for a session (2026-09-03
|
|
298
|
-
//
|
|
299
|
-
// so it is said rather than swallowed.
|
|
302
|
+
// went unnoticed for a session (2026-09-03). It is not repairable here, so it
|
|
303
|
+
// is said rather than swallowed.
|
|
300
304
|
if (typeof source.heldRanges !== "function" || typeof source.readHeld !== "function") {
|
|
301
305
|
logger.warn(
|
|
302
306
|
`subtitles: asked for cues of "${String(source.name ?? key).slice(0, 40)}" ` +
|
|
@@ -318,7 +322,7 @@ export async function cuesHeldFor(source, trackNumber) {
|
|
|
318
322
|
* The walk itself. Only ever entered through `cuesHeldFor`, which is what keeps
|
|
319
323
|
* one file to one walk at a time.
|
|
320
324
|
*
|
|
321
|
-
* @param {
|
|
325
|
+
* @param {HeldFile} source
|
|
322
326
|
* @param {object} state
|
|
323
327
|
* @param {object} plan
|
|
324
328
|
* @param {object} track
|
|
@@ -326,20 +330,24 @@ export async function cuesHeldFor(source, trackNumber) {
|
|
|
326
330
|
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
327
331
|
*/
|
|
328
332
|
async function walkFor(source, state, plan, track, trackNumber) {
|
|
329
|
-
|
|
330
|
-
// different list from what could be read when the plan was built.
|
|
331
|
-
state.held = await source.heldRanges();
|
|
332
|
-
const container = await containerOver(state, source);
|
|
333
|
+
const container = state.container;
|
|
333
334
|
if (!container) {
|
|
334
335
|
return { cues: [], coveredClusters: 0, indexedClusters: 0, track };
|
|
335
336
|
}
|
|
337
|
+
// Taken once per pass: pieces keep arriving, and what may be read now is a
|
|
338
|
+
// different list from what could be read when the plan was built.
|
|
339
|
+
const held = await heldReaderOf(source);
|
|
340
|
+
const { found, covered, indexed, withdrawn, more, stats } = await container.readHeldCues(plan, track, state.progress, held);
|
|
341
|
+
if (more) {
|
|
342
|
+
// Readable clusters were left for the next pass — the ones viewers stand in
|
|
343
|
+
// were read first and are pushed now. Asked for here, and not by a timer:
|
|
344
|
+
// nothing else would run the walk again on a file downloaded whole.
|
|
345
|
+
source.askAgain?.();
|
|
346
|
+
}
|
|
336
347
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
// them, which is the point: the container was chosen once, from the bytes.
|
|
341
|
-
const { found, covered, indexed } = await container.readHeldCues(plan, track, state);
|
|
342
|
-
|
|
348
|
+
for (const at of withdrawn ?? []) {
|
|
349
|
+
takeBack(state, at);
|
|
350
|
+
}
|
|
343
351
|
for (const [number, cues] of found) {
|
|
344
352
|
let into = state.cues.get(number);
|
|
345
353
|
if (!into) {
|
|
@@ -351,6 +359,7 @@ async function walkFor(source, state, plan, track, trackNumber) {
|
|
|
351
359
|
}
|
|
352
360
|
into.sort((left, right) => left.startSeconds - right.startSeconds);
|
|
353
361
|
}
|
|
362
|
+
reportWalk(source, state, stats, covered, indexed);
|
|
354
363
|
|
|
355
364
|
return {
|
|
356
365
|
cues: state.cues.get(trackNumber) ?? [],
|
|
@@ -360,31 +369,80 @@ async function walkFor(source, state, plan, track, trackNumber) {
|
|
|
360
369
|
};
|
|
361
370
|
}
|
|
362
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Take back every cue read from one position the container has withdrawn.
|
|
374
|
+
*
|
|
375
|
+
* @param {object} state
|
|
376
|
+
* @param {number} at
|
|
377
|
+
* @returns {void}
|
|
378
|
+
*/
|
|
379
|
+
function takeBack(state, at) {
|
|
380
|
+
for (const [number, cues] of state.cues) {
|
|
381
|
+
const kept = [];
|
|
382
|
+
for (const cue of cues) {
|
|
383
|
+
if (cue.source === at) {
|
|
384
|
+
const list = state.withdrawn.get(number) ?? [];
|
|
385
|
+
list.push(cue.seq);
|
|
386
|
+
state.withdrawn.set(number, list);
|
|
387
|
+
} else {
|
|
388
|
+
kept.push(cue);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
state.cues.set(number, kept);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* One line per file at most once a minute: what the walk has read, how, and
|
|
397
|
+
* what it could not.
|
|
398
|
+
*
|
|
399
|
+
* @param {HeldFile} source
|
|
400
|
+
* @param {object} state
|
|
401
|
+
* @param {object | undefined} stats
|
|
402
|
+
* @param {number} covered
|
|
403
|
+
* @param {number} indexed
|
|
404
|
+
* @returns {void}
|
|
405
|
+
*/
|
|
406
|
+
function reportWalk(source, state, stats, covered, indexed) {
|
|
407
|
+
if (!stats) {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const now = Date.now();
|
|
411
|
+
const summary = JSON.stringify({ covered, indexed, ...stats });
|
|
412
|
+
if (summary === state.lastStats || now - state.lastReportMs < WALK_REPORT_INTERVAL_MS) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
state.lastStats = summary;
|
|
416
|
+
state.lastReportMs = now;
|
|
417
|
+
logger.info(
|
|
418
|
+
`subtitles: walk of "${String(source.name).slice(0, 40)}" — ${covered} of ${indexed} known cluster(s) read, ` +
|
|
419
|
+
`${stats.fromSearch ?? 0} found by searching the bytes, ${stats.rejectedCandidates ?? 0} candidate(s) refused, ` +
|
|
420
|
+
`${stats.pendingCandidates ?? 0} waiting for bytes, ${stats.contradictions ?? 0} withdrawn, ` +
|
|
421
|
+
`${stats.unreadable ?? 0} unreadable, ${stats.refusedElements ?? 0} block(s) over one read, ` +
|
|
422
|
+
`${Math.round((stats.bytesWalked ?? 0) / 1048576)}MB read`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
363
426
|
/**
|
|
364
427
|
* Walk whatever clusters have newly arrived, for every text track a file
|
|
365
428
|
* carries, and report what is new since the last call — so the cues can be
|
|
366
429
|
* PUSHED to a browser rather than left for it to come back and ask.
|
|
367
430
|
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
* `getSubtitleCues` run ahead of being asked, on the same state that call
|
|
372
|
-
* itself would build — nothing is duplicated, and a file nobody has opened
|
|
373
|
-
* costs nothing beyond this.
|
|
431
|
+
* Only for a file somebody has asked about: its plan, its tracks or its cues.
|
|
432
|
+
* A file of the torrent nobody has opened costs nothing, and its head and Cues
|
|
433
|
+
* table are not fetched for this.
|
|
374
434
|
*
|
|
375
|
-
* @param {
|
|
376
|
-
* @
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* `trackIndex` is `declaredIndex` — the track's position among ALL the file's
|
|
381
|
-
* subtitle tracks, which is ffmpeg's `0:s:N` and the only number the browser
|
|
382
|
-
* knows. NOT the container's own track number, and not the position among the
|
|
383
|
-
* readable tracks either: counting those alone puts every text track after a
|
|
384
|
-
* picture-based one in the wrong place.
|
|
435
|
+
* @param {HeldFile} source
|
|
436
|
+
* @returns {Promise<{ trackIndex: number, cues: object[], withdrawn: number[], language: string }[]>}
|
|
437
|
+
* One entry per track that gained or lost a cue since the last call.
|
|
438
|
+
* `trackIndex` is `declaredIndex` — ffmpeg's `0:s:N`, the only number the
|
|
439
|
+
* browser knows.
|
|
385
440
|
*/
|
|
386
441
|
export async function warmSubtitleCues(source) {
|
|
387
442
|
const key = `${source.sourceKey}:${source.fileIndex}`;
|
|
443
|
+
if (!byFile.has(key) || byFile.get(key).forgotten === true) {
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
388
446
|
const plan = await planFor(source, key);
|
|
389
447
|
const state = stateFor(key);
|
|
390
448
|
const fresh = [];
|
|
@@ -394,49 +452,75 @@ export async function warmSubtitleCues(source) {
|
|
|
394
452
|
const held = await cuesHeldFor(source, track.trackNumber);
|
|
395
453
|
const since = state.pushed.get(track.trackNumber) ?? 0;
|
|
396
454
|
const newCues = held.cues.filter((cue) => (Number(cue.seq) || 0) > since);
|
|
397
|
-
|
|
455
|
+
const withdrawn = state.withdrawn.get(track.trackNumber) ?? [];
|
|
456
|
+
state.withdrawn.delete(track.trackNumber);
|
|
457
|
+
if (newCues.length === 0 && withdrawn.length === 0) {
|
|
398
458
|
continue;
|
|
399
459
|
}
|
|
400
460
|
const highest = newCues.reduce((max, cue) => Math.max(max, Number(cue.seq) || 0), since);
|
|
401
461
|
state.pushed.set(track.trackNumber, highest);
|
|
402
462
|
const codecId = held.track?.codecId ?? track.codecId;
|
|
403
463
|
const cues = TextSubtitleTrack.finalizeCues(newCues, codecId);
|
|
404
|
-
|
|
464
|
+
const entry = {
|
|
405
465
|
// ffmpeg's own numbering, which is the only one the browser knows.
|
|
406
466
|
trackIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
407
467
|
cues,
|
|
468
|
+
withdrawn,
|
|
408
469
|
language: held.track?.language ?? "",
|
|
409
470
|
// What the CUES say the language is, re-read on every push over every cue
|
|
410
|
-
// held so far
|
|
411
|
-
// no language is unreadable at the start of a session — a handful of cues
|
|
412
|
-
// is not a sample of a language, and the detector refuses to answer on one
|
|
413
|
-
// — so the answer has to be re-taken as the film downloads, and the label
|
|
414
|
-
// moved when it arrives. Costs about 6 ms per push, measured; pushes
|
|
415
|
-
// arrive about once a second per file being read.
|
|
471
|
+
// held so far. Used by the browser only where the container states none.
|
|
416
472
|
detectedLanguage: detectLanguage(
|
|
417
473
|
TextSubtitleTrack.finalizeCues(held.cues, codecId).map((cue) => cue.text).join("\n")
|
|
418
474
|
),
|
|
419
475
|
// Where the browser should resume from if it has to ask again — after a
|
|
420
476
|
// reconnect, which loses the subscription these pushes ride on.
|
|
421
477
|
cursor: highest,
|
|
422
|
-
// What this batch is ABOUT, in film time, so a log can be read against
|
|
423
|
-
// the position being played.
|
|
424
478
|
spanStartSeconds: cues.length > 0 ? cues[0].startSeconds : null,
|
|
425
479
|
spanEndSeconds: cues.length > 0 ? cues[cues.length - 1].endSeconds : null,
|
|
426
480
|
walkedClusters: held.coveredClusters ?? 0,
|
|
427
481
|
indexedClusters: held.indexedClusters ?? 0
|
|
428
|
-
}
|
|
482
|
+
};
|
|
483
|
+
noteDisagreement(source, state, track, entry.detectedLanguage);
|
|
484
|
+
fresh.push(entry);
|
|
429
485
|
}
|
|
430
486
|
return fresh;
|
|
431
487
|
}
|
|
432
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Say once when the text reads as another language than the format's default
|
|
491
|
+
* the container fell back to.
|
|
492
|
+
*
|
|
493
|
+
* The label does not move: RFC 8794 §11.1.19 makes the default the file's own
|
|
494
|
+
* statement. A disagreement is a muxer that left the element out for a track
|
|
495
|
+
* that is not English, and this line is what counts how often that happens.
|
|
496
|
+
*
|
|
497
|
+
* @param {HeldFile} source
|
|
498
|
+
* @param {object} state
|
|
499
|
+
* @param {object} track
|
|
500
|
+
* @param {{ code: string } | null} detected
|
|
501
|
+
* @returns {void}
|
|
502
|
+
*/
|
|
503
|
+
function noteDisagreement(source, state, track, detected) {
|
|
504
|
+
if (track.languageSource !== "default" || !detected?.code || state.disagreementSaid.has(track.trackNumber)) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const stated = String(track.language || "").toLowerCase();
|
|
508
|
+
const read = String(detected.code).toLowerCase();
|
|
509
|
+
if (stated === read || stated.startsWith(`${read}-`) || (stated === "eng" && read === "en")) {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
state.disagreementSaid.add(track.trackNumber);
|
|
513
|
+
logger.info(
|
|
514
|
+
`subtitles: "${String(source.name).slice(0, 40)}" track ${track.trackNumber} has no Language element, ` +
|
|
515
|
+
`so the file states ${track.language} by default, and its text reads as ${detected.code}`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
433
519
|
/**
|
|
434
520
|
* The text subtitle tracks of a file, for the menu the viewer sees.
|
|
435
521
|
*
|
|
436
|
-
* @param {
|
|
437
|
-
* @
|
|
438
|
-
* @param {string} sourceKey
|
|
439
|
-
* @returns {Promise<object[]>}
|
|
522
|
+
* @param {HeldFile} source
|
|
523
|
+
* @returns {Promise<object[]>} Empty while the plan's bytes have not arrived.
|
|
440
524
|
*/
|
|
441
525
|
export async function subtitleTracksOf(source) {
|
|
442
526
|
const plan = await planFor(source, `${source.sourceKey}:${source.fileIndex}`);
|
|
@@ -445,24 +529,18 @@ export async function subtitleTracksOf(source) {
|
|
|
445
529
|
declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
|
|
446
530
|
codecId: track.codecId,
|
|
447
531
|
language: track.language,
|
|
532
|
+
languageSource: track.languageSource ?? "",
|
|
448
533
|
name: track.name,
|
|
449
534
|
isDefault: track.isDefault,
|
|
450
|
-
indexedClusters: track.clusterPositions.length
|
|
535
|
+
indexedClusters: (track.clusterPositions ?? track.samples ?? []).length
|
|
451
536
|
}));
|
|
452
537
|
}
|
|
453
538
|
|
|
454
539
|
/**
|
|
455
540
|
* What the container itself says about its subtitle tracks, in its own order
|
|
456
|
-
* and including the picture-based ones
|
|
541
|
+
* and including the picture-based ones — lined up against ffmpeg's `0:s:N`.
|
|
457
542
|
*
|
|
458
|
-
*
|
|
459
|
-
* WebVTT and is indexed by position in the subtitle API. This one exists to be
|
|
460
|
-
* lined up against ffmpeg's `0:s:N` numbering, which counts every subtitle
|
|
461
|
-
* stream, so leaving the picture ones out would shift it.
|
|
462
|
-
*
|
|
463
|
-
* @param {object} torrent
|
|
464
|
-
* @param {number} fileIndex
|
|
465
|
-
* @param {string} sourceKey
|
|
543
|
+
* @param {HeldFile} source
|
|
466
544
|
* @returns {Promise<object[]>}
|
|
467
545
|
*/
|
|
468
546
|
export async function declaredSubtitleTracksOf(source) {
|
|
@@ -491,10 +569,7 @@ export function forgetSubtitles(sourceKey, fileIndex) {
|
|
|
491
569
|
}
|
|
492
570
|
|
|
493
571
|
/**
|
|
494
|
-
* Drop one file's state, but not while a walk of it is still running
|
|
495
|
-
* record of which clusters have been read lives in that state, and a walk left
|
|
496
|
-
* writing into a discarded copy while a new one starts beside it is the one
|
|
497
|
-
* path that defeats the serialization above.
|
|
572
|
+
* Drop one file's state, but not while a walk of it is still running.
|
|
498
573
|
*
|
|
499
574
|
* @param {string} key
|
|
500
575
|
* @returns {void}
|
|
@@ -504,9 +579,6 @@ function forgetOne(key) {
|
|
|
504
579
|
if (!state) {
|
|
505
580
|
return;
|
|
506
581
|
}
|
|
507
|
-
// Held, so that a walk started before this call is not left orphaned; the
|
|
508
|
-
// entry is dropped the moment the queue empties, and nothing is handed this
|
|
509
|
-
// state in the meantime.
|
|
510
582
|
state.forgotten = true;
|
|
511
583
|
void state.chain.then(() => {
|
|
512
584
|
if (byFile.get(key) === state) {
|