@torrent-tv/proxy 2.43.2 → 2.45.0
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 +11 -0
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +26 -8
- package/services/container-index/matroska-subtitles.js +24 -4
- package/services/hls-session-manager.js +35 -3
- package/services/playback-planner.js +574 -538
- package/services/subtitle-defaults.js +131 -0
- package/services/torrent-worker/pool-adapter.js +18 -0
- package/services/torrent-worker/subtitle-cues.js +97 -12
- package/services/torrent-worker/worker.js +5 -2
- package/test/cuts-follow-published-grid.test.js +73 -0
- package/test/subtitle-cursor.test.js +63 -0
- package/test/subtitle-defaults.test.js +97 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which subtitle track the FILE says to show, read from the file rather than
|
|
3
|
+
* from ffmpeg's description of it.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists. The browser decides which subtitle track to turn on from
|
|
6
|
+
* `isDefault`, and until now that came from ffmpeg's `-i` banner, which prints
|
|
7
|
+
* `(default)`. In Matroska `FlagDefault` DEFAULTS TO 1 and ffmpeg has already
|
|
8
|
+
* applied that default by the time it prints — so a file whose muxer wrote the
|
|
9
|
+
* flag on no track arrives looking exactly like one that wrote it on every
|
|
10
|
+
* track: everything marked. The banner cannot tell the two apart, and the
|
|
11
|
+
* difference is the whole question, because one of them means "show this one"
|
|
12
|
+
* and the other means "the file has no opinion".
|
|
13
|
+
*
|
|
14
|
+
* The container itself can be asked, and the EBML reader already walks the
|
|
15
|
+
* Tracks element for subtitle extraction. What it now also records is whether
|
|
16
|
+
* the element was WRITTEN, which is the fact the banner destroys.
|
|
17
|
+
*
|
|
18
|
+
* The awkward part is lining the two readings up. ffmpeg numbers its subtitle
|
|
19
|
+
* streams `0:s:0`, `0:s:1`, … over EVERY subtitle stream, picture-based ones
|
|
20
|
+
* included, in the order the container declares them; the container reading is
|
|
21
|
+
* a list in that same order. So position is the correspondence — but a position
|
|
22
|
+
* match that is merely assumed is worth nothing, so it is CHECKED: each pair
|
|
23
|
+
* has to agree on language or on title. One pair that agrees on neither, or a
|
|
24
|
+
* length that differs, means the two readings are not describing the same
|
|
25
|
+
* thing in the same order, and then the container reading is not used at all.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Language codes that carry no information, and so cannot confirm a pairing.
|
|
30
|
+
*
|
|
31
|
+
* ffmpeg prints `und` for a stream with no language; Matroska's own default
|
|
32
|
+
* for `Language` is `eng`, which is why an absent element cannot be read as a
|
|
33
|
+
* statement either — but `eng` is also a real answer, so it is not listed here
|
|
34
|
+
* and is compared like any other.
|
|
35
|
+
*/
|
|
36
|
+
const EMPTY_LANGUAGES = new Set(["", "und", "unknown"]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {unknown} value
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
function normalise(value) {
|
|
43
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether one banner stream and one container track can be the same track.
|
|
48
|
+
*
|
|
49
|
+
* Agreement on either the language or the name is enough; both being empty is
|
|
50
|
+
* not agreement, because two tracks that say nothing about themselves say
|
|
51
|
+
* nothing about their pairing either.
|
|
52
|
+
*
|
|
53
|
+
* @param {{ language?: string, title?: string }} banner
|
|
54
|
+
* @param {{ language?: string, name?: string }} container
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function pairingHolds(banner, container) {
|
|
58
|
+
const bannerLanguage = normalise(banner?.language);
|
|
59
|
+
const containerLanguage = normalise(container?.language);
|
|
60
|
+
if (
|
|
61
|
+
!EMPTY_LANGUAGES.has(bannerLanguage) &&
|
|
62
|
+
!EMPTY_LANGUAGES.has(containerLanguage) &&
|
|
63
|
+
bannerLanguage === containerLanguage
|
|
64
|
+
) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
const bannerTitle = normalise(banner?.title);
|
|
68
|
+
const containerName = normalise(container?.name);
|
|
69
|
+
if (bannerTitle.length > 0 && bannerTitle === containerName) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
// Nothing to compare on either side. Not a disagreement — a file may name
|
|
73
|
+
// neither — so it does not break the alignment; it simply adds no support.
|
|
74
|
+
return (
|
|
75
|
+
(EMPTY_LANGUAGES.has(bannerLanguage) || EMPTY_LANGUAGES.has(containerLanguage)) &&
|
|
76
|
+
(bannerTitle.length === 0 || containerName.length === 0)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The banner's subtitle tracks, with what the container says about each.
|
|
82
|
+
*
|
|
83
|
+
* Every returned track gains `declaresDefault`: whether the FILE wrote the flag
|
|
84
|
+
* for it. When the container reading cannot be trusted — no declarations, a
|
|
85
|
+
* different number of them, or a pair that agrees on neither language nor name
|
|
86
|
+
* — every track gets `declaresDefault: false` and its `isDefault` is left as
|
|
87
|
+
* the banner had it. That is the honest answer for a file we cannot read this
|
|
88
|
+
* way: the container has not been heard from, so nothing is shown unasked.
|
|
89
|
+
*
|
|
90
|
+
* @param {Array<{ index?: number, language?: string, title?: string, isDefault?: boolean }>} bannerTracks
|
|
91
|
+
* @param {Array<{ language?: string, name?: string, isDefault?: boolean, declaresDefault?: boolean }>} declared
|
|
92
|
+
* @returns {{ tracks: object[], aligned: boolean, reason: string }}
|
|
93
|
+
*/
|
|
94
|
+
export function mergeContainerSubtitleFlags(bannerTracks, declared) {
|
|
95
|
+
const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
|
|
96
|
+
const container = Array.isArray(declared) ? declared : [];
|
|
97
|
+
const undecided = () => ({
|
|
98
|
+
tracks: banner.map((track) => ({ ...track, declaresDefault: false }))
|
|
99
|
+
});
|
|
100
|
+
if (container.length === 0) {
|
|
101
|
+
return { ...undecided(), aligned: false, reason: "the container declares no subtitle track" };
|
|
102
|
+
}
|
|
103
|
+
if (container.length !== banner.length) {
|
|
104
|
+
return {
|
|
105
|
+
...undecided(),
|
|
106
|
+
aligned: false,
|
|
107
|
+
reason: `the container declares ${container.length} subtitle tracks and the probe found ${banner.length}`
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
for (const [order, track] of banner.entries()) {
|
|
111
|
+
if (!pairingHolds(track, container[order])) {
|
|
112
|
+
return {
|
|
113
|
+
...undecided(),
|
|
114
|
+
aligned: false,
|
|
115
|
+
reason:
|
|
116
|
+
`subtitle ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
|
|
117
|
+
`in the probe and "${normalise(container[order]?.name) || "-"}"/` +
|
|
118
|
+
`${normalise(container[order]?.language) || "-"} in the container`
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
tracks: banner.map((track, order) => ({
|
|
124
|
+
...track,
|
|
125
|
+
isDefault: container[order].isDefault === true,
|
|
126
|
+
declaresDefault: container[order].declaresDefault === true
|
|
127
|
+
})),
|
|
128
|
+
aligned: true,
|
|
129
|
+
reason: ""
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -157,6 +157,24 @@ export class WorkerTorrentPool {
|
|
|
157
157
|
return Array.isArray(answer?.tracks) ? answer.tracks : [];
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* What the container itself declares about its subtitle tracks, in its own
|
|
162
|
+
* order and including the picture-based ones — for lining up against
|
|
163
|
+
* ffmpeg's own numbering.
|
|
164
|
+
*
|
|
165
|
+
* @param {object} torrent
|
|
166
|
+
* @param {number} fileIndex
|
|
167
|
+
* @returns {Promise<object[]>}
|
|
168
|
+
*/
|
|
169
|
+
async getDeclaredSubtitleTracks(torrent, fileIndex) {
|
|
170
|
+
const sourceKey = torrent?.sourceKey;
|
|
171
|
+
if (!sourceKey) {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
const answer = await this.#client.getSubtitleTracks({ sourceKey, fileIndex });
|
|
175
|
+
return Array.isArray(answer?.declared) ? answer.declared : [];
|
|
176
|
+
}
|
|
177
|
+
|
|
160
178
|
/**
|
|
161
179
|
* The cues of one subtitle track that the downloaded clusters already carry.
|
|
162
180
|
*
|
|
@@ -99,14 +99,19 @@ function readHeld(file, start, end) {
|
|
|
99
99
|
async function planFor(torrent, fileIndex, key) {
|
|
100
100
|
let state = byFile.get(key);
|
|
101
101
|
if (!state) {
|
|
102
|
-
state = { plan: null, harvested: new Map(), cues: new Map() };
|
|
102
|
+
state = { plan: null, harvested: new Map(), cues: new Map(), seq: new Map(), walked: new Set() };
|
|
103
103
|
byFile.set(key, state);
|
|
104
104
|
}
|
|
105
105
|
if (state.plan !== null) {
|
|
106
106
|
return state.plan;
|
|
107
107
|
}
|
|
108
108
|
const file = torrent?.files?.[fileIndex];
|
|
109
|
-
|
|
109
|
+
// `declared` is what the container itself says about its subtitle tracks, in
|
|
110
|
+
// its own order. Empty means the container said nothing — which is a real
|
|
111
|
+
// answer and not a missing one: nothing is then shown unasked. An MP4 has no
|
|
112
|
+
// element that means "show this subtitle track by default", so it declares
|
|
113
|
+
// nothing however many tracks it carries.
|
|
114
|
+
const empty = { tracks: [], declared: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
|
|
110
115
|
if (!file) {
|
|
111
116
|
state.plan = empty;
|
|
112
117
|
return state.plan;
|
|
@@ -154,6 +159,31 @@ async function planFor(torrent, fileIndex, key) {
|
|
|
154
159
|
return state.plan;
|
|
155
160
|
}
|
|
156
161
|
|
|
162
|
+
/**
|
|
163
|
+
* The order a cue was FOUND in, which is the only cursor a browser can follow.
|
|
164
|
+
*
|
|
165
|
+
* A cue's TIME cannot serve as one. Cues are harvested out of whichever
|
|
166
|
+
* clusters happen to be downloaded, and those are not contiguous, so the set
|
|
167
|
+
* grows in the middle as well as at the end. A browser that remembered "the
|
|
168
|
+
* latest time I hold" and asked for everything past it would never be sent the
|
|
169
|
+
* cues that turn up BEHIND that mark afterwards — which is exactly the stretch
|
|
170
|
+
* it is about to play. Measured 2026-08-20 on a viewer at 272 s: one answer
|
|
171
|
+
* carried cues out to 1176 s, and from then on every cue between the two was
|
|
172
|
+
* filtered away for the rest of the session, with 59 of 276 clusters read.
|
|
173
|
+
*
|
|
174
|
+
* Found-order is monotonic by construction, so `?since=<n>` is exact however
|
|
175
|
+
* the file arrives.
|
|
176
|
+
*
|
|
177
|
+
* @param {{ seq: Map<number, number> }} state
|
|
178
|
+
* @param {number} trackNumber
|
|
179
|
+
* @returns {number}
|
|
180
|
+
*/
|
|
181
|
+
function nextSeq(state, trackNumber) {
|
|
182
|
+
const next = (state.seq.get(trackNumber) ?? 0) + 1;
|
|
183
|
+
state.seq.set(trackNumber, next);
|
|
184
|
+
return next;
|
|
185
|
+
}
|
|
186
|
+
|
|
157
187
|
/**
|
|
158
188
|
* Every cue of one track that can be read from what is already downloaded.
|
|
159
189
|
*
|
|
@@ -201,7 +231,12 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
|
|
|
201
231
|
harvested.add(sample.offset);
|
|
202
232
|
const text = decodeSubtitleSample(bytes, track.codecId);
|
|
203
233
|
if (text) {
|
|
204
|
-
cues.push({
|
|
234
|
+
cues.push({
|
|
235
|
+
startSeconds: sample.startSeconds,
|
|
236
|
+
endSeconds: sample.endSeconds,
|
|
237
|
+
text,
|
|
238
|
+
seq: nextSeq(state, trackNumber)
|
|
239
|
+
});
|
|
205
240
|
}
|
|
206
241
|
}
|
|
207
242
|
cues.sort((left, right) => left.startSeconds - right.startSeconds);
|
|
@@ -213,8 +248,24 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
|
|
|
213
248
|
};
|
|
214
249
|
}
|
|
215
250
|
|
|
216
|
-
for
|
|
217
|
-
|
|
251
|
+
// ONE walk for the whole file, not one per track. A Matroska cluster carries
|
|
252
|
+
// the blocks of every track that has anything to say over its span, so the
|
|
253
|
+
// bytes that answer one track answer them all — and reading them once per
|
|
254
|
+
// track meant the same cluster was fetched and parsed as many times as the
|
|
255
|
+
// film has subtitle tracks. Measured 2026-08-20 on a film with five: five
|
|
256
|
+
// requests every fifteen seconds, each costing 0.2-5.2 s of container
|
|
257
|
+
// reading, for cues that together weigh a few kilobytes.
|
|
258
|
+
//
|
|
259
|
+
// The union of the tracks' cluster lists is what gets walked: each track's
|
|
260
|
+
// list comes from its own Cues entries, so they overlap but do not coincide.
|
|
261
|
+
const positions = new Set();
|
|
262
|
+
for (const candidate of plan.tracks) {
|
|
263
|
+
for (const position of candidate.clusterPositions ?? []) {
|
|
264
|
+
positions.add(position);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const position of [...positions].sort((left, right) => left - right)) {
|
|
268
|
+
if (state.walked.has(position)) {
|
|
218
269
|
continue;
|
|
219
270
|
}
|
|
220
271
|
// The header first: it says how long the cluster is, and a cluster whose
|
|
@@ -225,7 +276,7 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
|
|
|
225
276
|
const probe = await readHeld(file, position, Math.min(file.length - 1, position + CLUSTER_HEADER_PROBE - 1));
|
|
226
277
|
const header = probe && [...iterateElements(probe, 0, probe.length)][0];
|
|
227
278
|
if (!header || header.size <= 0 || header.size > MAX_CLUSTER_BYTES) {
|
|
228
|
-
|
|
279
|
+
state.walked.add(position); // not a cluster we can read; do not look again
|
|
229
280
|
continue;
|
|
230
281
|
}
|
|
231
282
|
const last = Math.min(file.length - 1, position + header.dataOffset + header.size - 1);
|
|
@@ -236,18 +287,33 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
|
|
|
236
287
|
if (!bytes) {
|
|
237
288
|
continue;
|
|
238
289
|
}
|
|
239
|
-
|
|
240
|
-
for (const
|
|
241
|
-
cues.
|
|
290
|
+
state.walked.add(position);
|
|
291
|
+
for (const candidate of plan.tracks) {
|
|
292
|
+
let into = state.cues.get(candidate.trackNumber);
|
|
293
|
+
if (!into) {
|
|
294
|
+
into = [];
|
|
295
|
+
state.cues.set(candidate.trackNumber, into);
|
|
296
|
+
}
|
|
297
|
+
let found = false;
|
|
298
|
+
for (const cue of harvestCluster(bytes, candidate.trackNumber, plan.secondsPerTick)) {
|
|
299
|
+
cue.seq = nextSeq(state, candidate.trackNumber);
|
|
300
|
+
into.push(cue);
|
|
301
|
+
found = true;
|
|
302
|
+
}
|
|
303
|
+
if (found) {
|
|
304
|
+
into.sort((left, right) => left.startSeconds - right.startSeconds);
|
|
305
|
+
}
|
|
242
306
|
}
|
|
243
307
|
}
|
|
244
|
-
cues.sort((left, right) => left.startSeconds - right.startSeconds);
|
|
245
308
|
return {
|
|
246
|
-
cues,
|
|
247
|
-
|
|
309
|
+
cues: state.cues.get(trackNumber) ?? [],
|
|
310
|
+
// Every track is filled by the same walk, so this is a fact about the FILE
|
|
311
|
+
// and reads the same whichever track asked.
|
|
312
|
+
coveredClusters: state.walked.size,
|
|
248
313
|
indexedClusters: track.clusterPositions.length,
|
|
249
314
|
track
|
|
250
315
|
};
|
|
316
|
+
|
|
251
317
|
}
|
|
252
318
|
|
|
253
319
|
/**
|
|
@@ -270,6 +336,25 @@ export async function subtitleTracksOf(torrent, fileIndex, sourceKey) {
|
|
|
270
336
|
}));
|
|
271
337
|
}
|
|
272
338
|
|
|
339
|
+
/**
|
|
340
|
+
* What the container itself says about its subtitle tracks, in its own order
|
|
341
|
+
* and including the picture-based ones.
|
|
342
|
+
*
|
|
343
|
+
* Separate from `subtitleTracksOf`, which lists only what can be turned into
|
|
344
|
+
* WebVTT and is indexed by position in the subtitle API. This one exists to be
|
|
345
|
+
* lined up against ffmpeg's `0:s:N` numbering, which counts every subtitle
|
|
346
|
+
* stream, so leaving the picture ones out would shift it.
|
|
347
|
+
*
|
|
348
|
+
* @param {object} torrent
|
|
349
|
+
* @param {number} fileIndex
|
|
350
|
+
* @param {string} sourceKey
|
|
351
|
+
* @returns {Promise<object[]>}
|
|
352
|
+
*/
|
|
353
|
+
export async function declaredSubtitleTracksOf(torrent, fileIndex, sourceKey) {
|
|
354
|
+
const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
|
|
355
|
+
return plan?.declared ?? [];
|
|
356
|
+
}
|
|
357
|
+
|
|
273
358
|
/**
|
|
274
359
|
* Forget a file's cues — the torrent is gone, and holding them would keep the
|
|
275
360
|
* text of a film nobody is watching.
|
|
@@ -27,7 +27,7 @@ import { parentPort, workerData } from "node:worker_threads";
|
|
|
27
27
|
import { createSendStream } from "./channel.js";
|
|
28
28
|
import { createFileClaims } from "./file-claims.js";
|
|
29
29
|
import { readFragments, supplyFiguresFor } from "./piece-reader.js";
|
|
30
|
-
import { cuesHeldFor, subtitleTracksOf } from "./subtitle-cues.js";
|
|
30
|
+
import { cuesHeldFor, declaredSubtitleTracksOf, subtitleTracksOf } from "./subtitle-cues.js";
|
|
31
31
|
import { Command, Event } from "./protocol.js";
|
|
32
32
|
|
|
33
33
|
// Imported dynamically, and that is load-bearing: static imports are RESOLVED
|
|
@@ -359,7 +359,10 @@ async function runCommand(command, params, id) {
|
|
|
359
359
|
|
|
360
360
|
case Command.SUBTITLE_TRACKS: {
|
|
361
361
|
const torrent = await requireTorrent(params.sourceKey);
|
|
362
|
-
return {
|
|
362
|
+
return {
|
|
363
|
+
tracks: await subtitleTracksOf(torrent, params.fileIndex, params.sourceKey),
|
|
364
|
+
declared: await declaredSubtitleTracksOf(torrent, params.fileIndex, params.sourceKey)
|
|
365
|
+
};
|
|
363
366
|
}
|
|
364
367
|
|
|
365
368
|
case Command.SUBTITLE_CUES: {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A run cuts where the player was told the cuts are.
|
|
3
|
+
*
|
|
4
|
+
* There are two boundary tables. The live one is corrected as produced segments
|
|
5
|
+
* reveal where the file's cuts truly are; the published one is the snapshot the
|
|
6
|
+
* playlist text was written from, and a player places every fragment by that
|
|
7
|
+
* text and nothing else. The cut list handed to ffmpeg used to come from the
|
|
8
|
+
* live table, so every correction moved the run away from the timeline the
|
|
9
|
+
* player is reading.
|
|
10
|
+
*
|
|
11
|
+
* Field 2026-08-20, `Minions.and.Monsters.1080p.mkv`: the picture's segments
|
|
12
|
+
* arrived a uniform 2.002 s before the times its playlist named — 119 of 125 of
|
|
13
|
+
* them — against the 0.5 s hls.js bridges. A fragment that does not land is
|
|
14
|
+
* fetched again, and on 2026-08-17 two of them were fetched 1908 times each.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
|
|
20
|
+
import { HlsSessionManager, segmentCutTimesFrom } from "../services/hls-session-manager.js";
|
|
21
|
+
|
|
22
|
+
/** What the playlist in the player's hands says. */
|
|
23
|
+
const PUBLISHED = [0, 4.004, 8.008, 12.012, 16.016, 20.02];
|
|
24
|
+
/** The same grid after produced segments moved two of its cuts. */
|
|
25
|
+
const CORRECTED = [0, 4.004, 6.006, 12.012, 14.014, 20.02];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A session that has published one grid and since corrected another.
|
|
29
|
+
*
|
|
30
|
+
* @returns {{ manager: HlsSessionManager, session: object }}
|
|
31
|
+
*/
|
|
32
|
+
function sessionWithDriftedGrid() {
|
|
33
|
+
const manager = new HlsSessionManager({
|
|
34
|
+
enabled: true,
|
|
35
|
+
ffmpegBin: "ffmpeg",
|
|
36
|
+
localBindHost: "127.0.0.1",
|
|
37
|
+
localPort: 9090
|
|
38
|
+
});
|
|
39
|
+
const session = {
|
|
40
|
+
id: "picture",
|
|
41
|
+
segmentBoundaries: [...CORRECTED],
|
|
42
|
+
publishedBoundaries: [...PUBLISHED]
|
|
43
|
+
};
|
|
44
|
+
return { manager, session };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("the cut list is the one the playlist was written from", () => {
|
|
48
|
+
const { manager, session } = sessionWithDriftedGrid();
|
|
49
|
+
const grid = manager.publishedGridFor(session);
|
|
50
|
+
assert.deepEqual(grid, PUBLISHED);
|
|
51
|
+
// Interior cuts of a run starting at #1, rebased on the run's own start
|
|
52
|
+
// (4.004 s), and stopping before the last entry, which is the file's end.
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
segmentCutTimesFrom(grid, 1).map((time) => Number(time.toFixed(3))),
|
|
55
|
+
[4.004, 8.008, 12.012]
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a corrected grid does not move the cuts of a session already being read", () => {
|
|
60
|
+
const { manager, session } = sessionWithDriftedGrid();
|
|
61
|
+
const fromCorrected = segmentCutTimesFrom(session.segmentBoundaries, 1);
|
|
62
|
+
const fromPublished = segmentCutTimesFrom(manager.publishedGridFor(session), 1);
|
|
63
|
+
assert.notDeepEqual(fromCorrected, fromPublished);
|
|
64
|
+
// The gap the field measured: the corrected table would have cut #2 two
|
|
65
|
+
// seconds early, which is four times what a player bridges.
|
|
66
|
+
assert.equal(Number((fromPublished[0] - fromCorrected[0]).toFixed(3)), 2.002);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("a session that published no grid falls back to the live one", () => {
|
|
70
|
+
const { manager } = sessionWithDriftedGrid();
|
|
71
|
+
const session = { id: "no-playlist", segmentBoundaries: [...CORRECTED], publishedBoundaries: [] };
|
|
72
|
+
assert.deepEqual(manager.publishedGridFor(session), CORRECTED);
|
|
73
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The rule this pins: a subtitle cursor counts the order cues were FOUND, not
|
|
6
|
+
* where they sit in the film.
|
|
7
|
+
*
|
|
8
|
+
* Cues are read out of whichever clusters happen to be downloaded, and a
|
|
9
|
+
* torrent does not arrive in film order — a seek pulls a later stretch first,
|
|
10
|
+
* and the earlier one fills in afterwards. So the set of known cues grows in
|
|
11
|
+
* the MIDDLE as well as at the end.
|
|
12
|
+
*
|
|
13
|
+
* A cursor in film time cannot survive that: measured 2026-08-20 on a viewer at
|
|
14
|
+
* 272 s, one answer carried cues out to 1176 s, and from that moment every cue
|
|
15
|
+
* between the two was filtered away for the rest of the session — the stretch
|
|
16
|
+
* they were about to watch. 59 of 276 clusters had been read.
|
|
17
|
+
*
|
|
18
|
+
* The filter below is the route's, written out so the property can be checked
|
|
19
|
+
* without a torrent: `?since=<n>` selects by found-order, `?after=<seconds>` is
|
|
20
|
+
* the old behaviour kept for an older browser.
|
|
21
|
+
*/
|
|
22
|
+
const bySince = (cues, since) => cues.filter((cue) => (Number(cue.seq) || 0) > since);
|
|
23
|
+
const byAfter = (cues, after) => cues.filter((cue) => cue.startSeconds > after);
|
|
24
|
+
|
|
25
|
+
/** A late-arriving cluster from EARLIER in the film than what is already held. */
|
|
26
|
+
const held = [
|
|
27
|
+
{ startSeconds: 40, text: "found first, early in the film", seq: 1 },
|
|
28
|
+
{ startSeconds: 1176, text: "found second, far ahead", seq: 2 },
|
|
29
|
+
{ startSeconds: 300, text: "found third, behind the furthest held", seq: 3 }
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
test("a cue found after a further-ahead one is still delivered", () => {
|
|
33
|
+
// The browser holds seq 1 and 2 and asks for what came after.
|
|
34
|
+
const fresh = bySince(held, 2);
|
|
35
|
+
assert.deepEqual(fresh.map((cue) => cue.startSeconds), [300]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the same case in film time loses that cue for ever", () => {
|
|
39
|
+
// This is what shipped in 2.43.1 and what the field session showed: the
|
|
40
|
+
// browser's furthest cue is 1176 s, so the 300 s cue can never reach it.
|
|
41
|
+
const fresh = byAfter(held, 1176);
|
|
42
|
+
assert.deepEqual(fresh.map((cue) => cue.startSeconds), []);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a browser asking for the first time is sent everything", () => {
|
|
46
|
+
assert.equal(bySince(held, 0).length, 3);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("nothing new is answered with nothing", () => {
|
|
50
|
+
assert.deepEqual(bySince(held, 3), []);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("the cursor to send back is the highest found-order held", () => {
|
|
54
|
+
const cursor = held.reduce((highest, cue) => Math.max(highest, Number(cue.seq) || 0), 0);
|
|
55
|
+
assert.equal(cursor, 3);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a cursor is unaffected by the order cues are sorted into", () => {
|
|
59
|
+
// The list is kept in film order for the WebVTT it becomes; the cursor must
|
|
60
|
+
// not depend on that.
|
|
61
|
+
const sorted = [...held].sort((left, right) => left.startSeconds - right.startSeconds);
|
|
62
|
+
assert.deepEqual(bySince(sorted, 2).map((cue) => cue.startSeconds), [300]);
|
|
63
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mergeContainerSubtitleFlags, pairingHolds } from "../services/subtitle-defaults.js";
|
|
4
|
+
|
|
5
|
+
test("a flag the container wrote is carried through, and one it did not is not", () => {
|
|
6
|
+
const banner = [
|
|
7
|
+
{ index: 0, language: "rus", title: "Forced", isDefault: true },
|
|
8
|
+
{ index: 1, language: "eng", title: "SDH", isDefault: true }
|
|
9
|
+
];
|
|
10
|
+
const declared = [
|
|
11
|
+
{ language: "rus", name: "Forced", isDefault: true, declaresDefault: true },
|
|
12
|
+
{ language: "eng", name: "SDH", isDefault: true, declaresDefault: false }
|
|
13
|
+
];
|
|
14
|
+
const merged = mergeContainerSubtitleFlags(banner, declared);
|
|
15
|
+
assert.equal(merged.aligned, true);
|
|
16
|
+
assert.deepEqual(
|
|
17
|
+
merged.tracks.map((track) => [track.isDefault, track.declaresDefault]),
|
|
18
|
+
[[true, true], [true, false]]
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("a file that wrote nothing is reported as having written nothing, though ffmpeg marked everything", () => {
|
|
23
|
+
// This is the case the banner cannot express: `FlagDefault` defaults to 1, so
|
|
24
|
+
// ffmpeg prints `(default)` against every track of a file that chose none.
|
|
25
|
+
const banner = [
|
|
26
|
+
{ index: 0, language: "rus", title: "", isDefault: true },
|
|
27
|
+
{ index: 1, language: "eng", title: "", isDefault: true }
|
|
28
|
+
];
|
|
29
|
+
const declared = [
|
|
30
|
+
{ language: "rus", name: "", isDefault: true, declaresDefault: false },
|
|
31
|
+
{ language: "eng", name: "", isDefault: true, declaresDefault: false }
|
|
32
|
+
];
|
|
33
|
+
const merged = mergeContainerSubtitleFlags(banner, declared);
|
|
34
|
+
assert.equal(merged.aligned, true);
|
|
35
|
+
assert.deepEqual(merged.tracks.map((track) => track.declaresDefault), [false, false]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the container's own answer overrides the banner's, in both directions", () => {
|
|
39
|
+
const banner = [
|
|
40
|
+
{ index: 0, language: "rus", title: "a", isDefault: true },
|
|
41
|
+
{ index: 1, language: "eng", title: "b", isDefault: false }
|
|
42
|
+
];
|
|
43
|
+
const declared = [
|
|
44
|
+
{ language: "rus", name: "a", isDefault: false, declaresDefault: true },
|
|
45
|
+
{ language: "eng", name: "b", isDefault: true, declaresDefault: true }
|
|
46
|
+
];
|
|
47
|
+
const merged = mergeContainerSubtitleFlags(banner, declared);
|
|
48
|
+
assert.deepEqual(merged.tracks.map((track) => track.isDefault), [false, true]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a count that differs means the two readings are not the same list", () => {
|
|
52
|
+
// The container declares a picture track the probe did not report; position
|
|
53
|
+
// is then not the correspondence and nothing may be read across.
|
|
54
|
+
const banner = [{ index: 0, language: "rus", title: "", isDefault: true }];
|
|
55
|
+
const declared = [
|
|
56
|
+
{ language: "rus", name: "", isDefault: false, declaresDefault: true },
|
|
57
|
+
{ language: "eng", name: "", isDefault: true, declaresDefault: true }
|
|
58
|
+
];
|
|
59
|
+
const merged = mergeContainerSubtitleFlags(banner, declared);
|
|
60
|
+
assert.equal(merged.aligned, false);
|
|
61
|
+
assert.match(merged.reason, /declares 2 subtitle tracks and the probe found 1/);
|
|
62
|
+
assert.deepEqual(merged.tracks.map((track) => [track.isDefault, track.declaresDefault]), [[true, false]]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a pair agreeing on neither language nor name refuses the whole alignment", () => {
|
|
66
|
+
const banner = [
|
|
67
|
+
{ index: 0, language: "rus", title: "", isDefault: true },
|
|
68
|
+
{ index: 1, language: "eng", title: "", isDefault: true }
|
|
69
|
+
];
|
|
70
|
+
const declared = [
|
|
71
|
+
{ language: "eng", name: "", isDefault: true, declaresDefault: true },
|
|
72
|
+
{ language: "rus", name: "", isDefault: false, declaresDefault: true }
|
|
73
|
+
];
|
|
74
|
+
const merged = mergeContainerSubtitleFlags(banner, declared);
|
|
75
|
+
assert.equal(merged.aligned, false);
|
|
76
|
+
assert.deepEqual(merged.tracks.map((track) => track.declaresDefault), [false, false]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a container that declares nothing leaves the banner alone", () => {
|
|
80
|
+
const banner = [{ index: 0, language: "rus", title: "", isDefault: true }];
|
|
81
|
+
const merged = mergeContainerSubtitleFlags(banner, []);
|
|
82
|
+
assert.equal(merged.aligned, false);
|
|
83
|
+
assert.equal(merged.tracks[0].isDefault, true);
|
|
84
|
+
assert.equal(merged.tracks[0].declaresDefault, false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a name confirms a pairing when the languages are unstated", () => {
|
|
88
|
+
assert.equal(pairingHolds({ language: "und", title: "Forced" }, { language: "", name: "Forced" }), true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("two tracks that say nothing about themselves do not break the alignment", () => {
|
|
92
|
+
assert.equal(pairingHolds({ language: "und", title: "" }, { language: "", name: "" }), true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a stated language that differs is a disagreement", () => {
|
|
96
|
+
assert.equal(pairingHolds({ language: "rus", title: "" }, { language: "eng", name: "" }), false);
|
|
97
|
+
});
|